diff --git a/examples/config-metrics.json b/examples/config-metrics.json index 7ec5845c16..2b94e25835 100644 --- a/examples/config-metrics.json +++ b/examples/config-metrics.json @@ -5,7 +5,12 @@ }, "http": { "address": "127.0.0.1", - "port": "8080" + "port": "8080", + "auth": { + "htpasswd": { + "path": "test/data/htpasswd" + } + } }, "log": { "level": "debug" diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 8a8b428496..675c1fc1d0 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -60,7 +60,7 @@ func AuthHandler(ctlr *Controller) mux.MiddlewareFunc { return bearerAuthHandler(ctlr) } - return authnMiddleware.TryAuthnHandlers(ctlr) + return authnMiddleware.tryAuthnHandlers(ctlr) } func (amw *AuthnMiddleware) sessionAuthn(ctlr *Controller, userAc *reqCtx.UserAccessControl, @@ -250,7 +250,7 @@ func (amw *AuthnMiddleware) basicAuthn(ctlr *Controller, userAc *reqCtx.UserAcce return false, nil } -func (amw *AuthnMiddleware) TryAuthnHandlers(ctlr *Controller) mux.MiddlewareFunc { //nolint: gocyclo +func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFunc { //nolint: gocyclo // no password based authN, if neither LDAP nor HTTP BASIC is enabled if !ctlr.Config.IsBasicAuthnEnabled() { return noPasswdAuth(ctlr) diff --git a/pkg/api/authn_test.go b/pkg/api/authn_test.go index b989d7865d..8062243681 100644 --- a/pkg/api/authn_test.go +++ b/pkg/api/authn_test.go @@ -1,37 +1,52 @@ -//go:build mgmt -// +build mgmt +//go:build sync && scrub && metrics && search && lint && userprefs && mgmt && imagetrust && ui +// +build sync,scrub,metrics,search,lint,userprefs,mgmt,imagetrust,ui package api_test import ( "context" + "crypto/tls" + "crypto/x509" "encoding/json" - "errors" + goerrors "errors" "fmt" + "net" "net/http" "net/http/httptest" + "net/url" "os" + "path/filepath" + "strconv" + "strings" "testing" "time" guuid "github.com/gofrs/uuid" + "github.com/gorilla/securecookie" + "github.com/gorilla/sessions" + godigest "github.com/opencontainers/go-digest" "github.com/project-zot/mockoidc" . "github.com/smartystreets/goconvey/convey" "gopkg.in/resty.v1" + "zotregistry.io/zot/errors" "zotregistry.io/zot/pkg/api" "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/api/constants" + apiErr "zotregistry.io/zot/pkg/api/errors" extconf "zotregistry.io/zot/pkg/extensions/config" "zotregistry.io/zot/pkg/log" mTypes "zotregistry.io/zot/pkg/meta/types" reqCtx "zotregistry.io/zot/pkg/requestcontext" + storageConstants "zotregistry.io/zot/pkg/storage/constants" authutils "zotregistry.io/zot/pkg/test/auth" test "zotregistry.io/zot/pkg/test/common" + "zotregistry.io/zot/pkg/test/deprecated" + . "zotregistry.io/zot/pkg/test/image-utils" "zotregistry.io/zot/pkg/test/mocks" ) -var ErrUnexpectedError = errors.New("error: unexpected error") +var ErrUnexpectedError = goerrors.New("error: unexpected error") type ( apiKeyResponse struct { @@ -46,239 +61,2699 @@ type ( } ) -func TestAllowedMethodsHeaderAPIKey(t *testing.T) { - defaultVal := true - - Convey("Test http options response", t, func() { - conf := config.New() +func TestHtpasswdSingleCred(t *testing.T) { + Convey("Single cred", t, func() { port := test.GetFreePort() - conf.HTTP.Port = port - conf.HTTP.Auth.APIKey = defaultVal baseURL := test.GetBaseURL(port) + singleCredtests := []string{} + user := ALICE + password := ALICE + singleCredtests = append(singleCredtests, getCredString(user, password)) + singleCredtests = append(singleCredtests, getCredString(user, password)+"\n") + + for _, testString := range singleCredtests { + func() { + conf := config.New() + conf.HTTP.Port = port + + htpasswdPath := test.MakeHtpasswdFileFromString(testString) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } - ctlr := api.NewController(conf) - ctlr.Config.Storage.RootDirectory = t.TempDir() + conf.HTTP.AllowOrigin = conf.HTTP.Address - ctrlManager := test.NewControllerManager(ctlr) + ctlr := makeController(conf, t.TempDir()) - ctrlManager.StartAndWait(port) - defer ctrlManager.StopServer() + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - resp, _ := resty.R().Options(baseURL + constants.APIKeyPath) - So(resp, ShouldNotBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,POST,DELETE,OPTIONS") - So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + // with creds, should get expected status code + resp, _ := resty.R().SetBasicAuth(user, password).Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + header := []string{"Authorization,content-type," + constants.SessionClientHeaderName} + + resp, _ = resty.R().SetBasicAuth(user, password).Options(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + So(len(resp.Header()), ShouldEqual, 5) + So(resp.Header()["Access-Control-Allow-Headers"], ShouldResemble, header) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") + + // with invalid creds, it should fail + resp, _ = resty.R().SetBasicAuth("chuck", "chuck").Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }() + } }) } -func TestAPIKeys(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) +func TestHtpasswdTwoCreds(t *testing.T) { + Convey("Two creds", t, func() { + twoCredTests := []string{} + user1 := "alicia" + password1 := "aliciapassword" + user2 := "bob" + password2 := "robert" + twoCredTests = append(twoCredTests, getCredString(user1, password1)+"\n"+ + getCredString(user2, password2)) + + twoCredTests = append(twoCredTests, getCredString(user1, password1)+"\n"+ + getCredString(user2, password2)+"\n") + + twoCredTests = append(twoCredTests, getCredString(user1, password1)+"\n\n"+ + getCredString(user2, password2)+"\n\n") + + for _, testString := range twoCredTests { + func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + htpasswdPath := test.MakeHtpasswdFileFromString(testString) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + ctlr := makeController(conf, t.TempDir()) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // with creds, should get expected status code + resp, _ := resty.R().SetBasicAuth(user1, password1).Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, _ = resty.R().SetBasicAuth(user2, password2).Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with invalid creds, it should fail + resp, _ = resty.R().SetBasicAuth("chuck", "chuck").Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }() + } + }) +} - conf := config.New() - conf.HTTP.Port = port +func TestHtpasswdFiveCreds(t *testing.T) { + Convey("Five creds", t, func() { + tests := map[string]string{ + "michael": "scott", + "jim": "halpert", + "dwight": "shrute", + "pam": "bessley", + "creed": "bratton", + } + credString := strings.Builder{} + for key, val := range tests { + credString.WriteString(getCredString(key, val) + "\n") + } - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) + func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + htpasswdPath := test.MakeHtpasswdFileFromString(credString.String()) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + ctlr := makeController(conf, t.TempDir()) - mockOIDCServer, err := authutils.MockOIDCRun() - if err != nil { - panic(err) - } + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) + // with creds, should get expected status code + for key, val := range tests { + resp, _ := resty.R().SetBasicAuth(key, val).Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) } + + // with invalid creds, it should fail + resp, _ := resty.R().SetBasicAuth("chuck", "chuck").Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) }() + }) +} - mockOIDCConfig := mockOIDCServer.Config() - defaultVal := true +func TestAllowMethodsHeader(t *testing.T) { + Convey("Options request", t, func() { + dir := t.TempDir() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = dir + conf.HTTP.AllowOrigin = "someOrigin" + + simpleUser := "simpleUser" + simpleUserPassword := "simpleUserPass" + credTests := fmt.Sprintf("%s\n\n", getCredString(simpleUser, simpleUserPassword)) + + htpasswdPath := test.MakeHtpasswdFileFromString(credTests) + defer os.Remove(htpasswdPath) 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", "groups"}, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{simpleUser}, + Actions: []string{"read", "create"}, + }, }, }, }, - APIKey: defaultVal, } - conf.HTTP.AccessControl = &config.AccessControlConfig{} - - conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Search = &extconf.SearchConfig{} - conf.Extensions.Search.Enable = &defaultVal - conf.Extensions.Search.CVE = nil - conf.Extensions.UI = &extconf.UIConfig{} - conf.Extensions.UI.Enable = &defaultVal + defaultVal := true + conf.Extensions = &extconf.ExtensionConfig{ + Search: &extconf.SearchConfig{BaseConfig: extconf.BaseConfig{Enable: &defaultVal}}, + } ctlr := api.NewController(conf) - dir := t.TempDir() - ctlr.Config.Storage.RootDirectory = dir + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() - cm := test.NewControllerManager(ctlr) + simpleUserClient := resty.R().SetBasicAuth(simpleUser, simpleUserPassword) - cm.StartServer() - defer cm.StopServer() - test.WaitTillServerReady(baseURL) + digest := godigest.FromString("digest") - payload := api.APIKeyPayload{ - Label: "test", - Scopes: []string{"test"}, - } - reqBody, err := json.Marshal(payload) + // /v2 + resp, err := simpleUserClient.Options(baseURL + "/v2/") So(err, ShouldBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - Convey("API key retrieved with basic auth", func() { - resp, err := resty.R(). - SetBody(reqBody). - SetBasicAuth("test", "test"). - Post(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + // /v2/{name}/tags/list + resp, err = simpleUserClient.Options(baseURL + "/v2/reponame/tags/list") + So(err, ShouldBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - user := mockoidc.DefaultUser() + // /v2/{name}/manifests/{reference} + resp, err = simpleUserClient.Options(baseURL + "/v2/reponame/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "HEAD,GET,OPTIONS") - // get API key and email from apikey route response - var apiKeyResponse apiKeyResponse - err = json.Unmarshal(resp.Body(), &apiKeyResponse) - So(err, ShouldBeNil) + // /v2/{name}/referrers/{digest} + resp, err = simpleUserClient.Options(baseURL + "/v2/reponame/referrers/" + digest.String()) + So(err, ShouldBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - email := user.Email - So(email, ShouldNotBeEmpty) + // /v2/_catalog + resp, err = simpleUserClient.Options(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - resp, err = resty.R(). - SetBasicAuth("test", apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // /v2/_oci/ext/discover + resp, err = simpleUserClient.Options(baseURL + "/v2/_oci/ext/discover") + So(err, ShouldBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") + }) +} - // get API key list with basic auth - resp, err = resty.R(). - SetBasicAuth("test", "test"). - Get(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) +func TestTLSWithBasicAuth(t *testing.T) { + Convey("Make a new controller", t, func() { + caCert, err := os.ReadFile(CACert) + So(err, ShouldBeNil) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) - var apiKeyListResponse apiKeyListResponse - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) - So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) - So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) - So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) - So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) - So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) - So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) - // add another one - resp, err = resty.R(). - SetBody(reqBody). - SetBasicAuth("test", "test"). - Post(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + defer func() { resty.SetTLSClientConfig(nil) }() + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + } + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } - err = json.Unmarshal(resp.Body(), &apiKeyResponse) - So(err, ShouldBeNil) + ctlr := makeController(conf, t.TempDir()) - resp, err = resty.R(). - SetBasicAuth("test", apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - // get API key list with api key auth - resp, err = resty.R(). - SetBasicAuth("test", apiKeyResponse.APIKey). - Get(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // accessing insecure HTTP site should fail + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) - So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 2) - }) + // without creds, should get access error + resp, err = resty.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + var e apiErr.Error + err = json.Unmarshal(resp.Body(), &e) + So(err, ShouldBeNil) - Convey("API key retrieved with openID and with no expire", func() { - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // with creds, should get expected status code + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) +} - cookies := resp.Cookies() +func TestTLSWithBasicAuthAllowReadAccess(t *testing.T) { + Convey("Make a new controller", t, func() { + caCert, err := os.ReadFile(CACert) + So(err, ShouldBeNil) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) - // call endpoint without session - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) - client.SetCookies(cookies) + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + defer func() { resty.SetTLSClientConfig(nil) }() + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.TLS = &config.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + } - // call endpoint with session ( added to client after previous request) - resp, err = client.R(). - SetBody(reqBody). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + }, + } - user := mockoidc.DefaultUser() + ctlr := makeController(conf, t.TempDir()) - // get API key and email from apikey route response - var apiKeyResponse apiKeyResponse - err = json.Unmarshal(resp.Body(), &apiKeyResponse) - So(err, ShouldBeNil) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - email := user.Email - So(email, ShouldNotBeEmpty) + // accessing insecure HTTP site should fail + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - // get API key list - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // without creds, should still be allowed to access + resp, err = resty.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var apiKeyListResponse apiKeyListResponse + // with creds, should get expected status code + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) - So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // without creds, writes should fail + resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) +} + +func TestMutualTLSAuthWithUserPermissions(t *testing.T) { + Convey("Make a new controller", t, func() { + caCert, err := os.ReadFile(CACert) + So(err, ShouldBeNil) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + defer func() { resty.SetTLSClientConfig(nil) }() + conf := config.New() + conf.HTTP.Port = port + + conf.HTTP.TLS = &config.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"*"}, + Actions: []string{"read"}, + }, + }, + }, + }, + } + + ctlr := makeController(conf, t.TempDir()) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") + So(err, ShouldBeNil) + + resty.SetCertificates(cert) + defer func() { resty.SetCertificates(tls.Certificate{}) }() + + // with client certs but without creds, should succeed + resp, err = resty.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = resty.R().Get(secureBaseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with creds, should get expected status code + resp, _ = resty.R().Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // reading a repo should not get 403 + resp, err = resty.R().Get(secureBaseURL + "/v2/repo/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // without creds, writes should fail + resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // empty default authorization and give user the permission to create + repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create") + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + }) +} + +func TestMutualTLSAuthWithoutCN(t *testing.T) { + Convey("Make a new controller", t, func() { + caCert, err := os.ReadFile("../../test/data/noidentity/ca.crt") + So(err, ShouldBeNil) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + port := test.GetFreePort() + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + defer func() { resty.SetTLSClientConfig(nil) }() + conf := config.New() + conf.HTTP.Port = port + + conf.HTTP.TLS = &config.TLSConfig{ + Cert: "../../test/data/noidentity/server.cert", + Key: "../../test/data/noidentity/server.key", + CACert: "../../test/data/noidentity/ca.crt", + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{"*"}, + Actions: []string{"read"}, + }, + }, + }, + }, + } + + ctlr := makeController(conf, t.TempDir()) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair("../../test/data/noidentity/client.cert", "../../test/data/noidentity/client.key") + So(err, ShouldBeNil) + + resty.SetCertificates(cert) + defer func() { resty.SetCertificates(tls.Certificate{}) }() + + // with client certs but without TLS mutual auth setup should get certificate error + resp, _ := resty.R().Get(secureBaseURL + "/v2/_catalog") + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) +} + +func TestTLSMutualAuth(t *testing.T) { + Convey("Make a new controller", t, func() { + caCert, err := os.ReadFile(CACert) + So(err, ShouldBeNil) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + defer func() { resty.SetTLSClientConfig(nil) }() + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } + + ctlr := makeController(conf, t.TempDir()) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // accessing insecure HTTP site should fail + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // without client certs and creds, should get conn error + _, err = resty.R().Get(secureBaseURL) + So(err, ShouldNotBeNil) + + // with creds but without certs, should get conn error + _, err = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(err, ShouldNotBeNil) + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") + So(err, ShouldBeNil) + + resty.SetCertificates(cert) + defer func() { resty.SetCertificates(tls.Certificate{}) }() + + // with client certs but without creds, should succeed + resp, err = resty.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with client certs and creds, should get expected status code + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // with client certs, creds shouldn't matter + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) +} + +func TestTLSMutualAuthAllowReadAccess(t *testing.T) { + Convey("Make a new controller", t, func() { + caCert, err := os.ReadFile(CACert) + So(err, ShouldBeNil) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + defer func() { resty.SetTLSClientConfig(nil) }() + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.TLS = &config.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + }, + } + + ctlr := makeController(conf, t.TempDir()) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // accessing insecure HTTP site should fail + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // without client certs and creds, reads are allowed + resp, err = resty.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with creds but without certs, reads are allowed + resp, err = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // without creds, writes should fail + resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") + So(err, ShouldBeNil) + + resty.SetCertificates(cert) + defer func() { resty.SetCertificates(tls.Certificate{}) }() + + // with client certs but without creds, should succeed + resp, err = resty.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with client certs and creds, should get expected status code + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + // with client certs, creds shouldn't matter + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) +} + +func TestTLSMutualAndBasicAuth(t *testing.T) { + Convey("Make a new controller", t, func() { + caCert, err := os.ReadFile(CACert) + So(err, ShouldBeNil) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + defer func() { resty.SetTLSClientConfig(nil) }() + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.TLS = &config.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } + + ctlr := makeController(conf, t.TempDir()) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // accessing insecure HTTP site should fail + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // without client certs and creds, should fail + _, err = resty.R().Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // with creds but without certs, should succeed + _, err = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") + So(err, ShouldBeNil) + + resty.SetCertificates(cert) + defer func() { resty.SetCertificates(tls.Certificate{}) }() + + // with client certs but without creds, should get access error + resp, err = resty.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // with client certs and creds, should get expected status code + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) +} + +func TestTLSMutualAndBasicAuthAllowReadAccess(t *testing.T) { + Convey("Make a new controller", t, func() { + caCert, err := os.ReadFile(CACert) + So(err, ShouldBeNil) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + secureBaseURL := test.GetSecureBaseURL(port) + + resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) + defer func() { resty.SetTLSClientConfig(nil) }() + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.TLS = &config.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + CACert: CACert, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + }, + } + + ctlr := makeController(conf, t.TempDir()) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // accessing insecure HTTP site should fail + resp, err := resty.R().Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // without client certs and creds, should fail + _, err = resty.R().Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // with creds but without certs, should succeed + _, err = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // setup TLS mutual auth + cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") + So(err, ShouldBeNil) + + resty.SetCertificates(cert) + defer func() { resty.SetCertificates(tls.Certificate{}) }() + + // with client certs but without creds, reads should succeed + resp, err = resty.R().Get(secureBaseURL + "/v2/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with only client certs, writes should fail + resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // with client certs and creds, should get expected status code + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) +} + +func TestBearerAuthWithAllowReadAccess(t *testing.T) { + Convey("Make a new controller", t, func() { + authTestServer := authutils.MakeAuthTestServer(ServerKey, UnauthorizedNamespace) + defer authTestServer.Close() + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + + aurl, err := url.Parse(authTestServer.URL) + So(err, ShouldBeNil) + + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Cert: ServerCert, + Realm: authTestServer.URL + "/auth/token", + Service: aurl.Host, + }, + } + ctlr := makeController(conf, t.TempDir()) + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + }, + } + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + blob := []byte("hello, blob!") + digest := godigest.FromBytes(blob).String() + + resp, err := resty.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + authorizationHeader := authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var goodToken authutils.AccessTokenResponse + err = json.Unmarshal(resp.Body(), &goodToken) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = resty.R().Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + err = json.Unmarshal(resp.Body(), &goodToken) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := resp.Header().Get("Location") + + resp, err = resty.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + err = json.Unmarshal(resp.Body(), &goodToken) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + Get(baseURL + "/v2/" + AuthorizedNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + err = json.Unmarshal(resp.Body(), &goodToken) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + Get(baseURL + "/v2/" + AuthorizedNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = resty.R(). + Post(baseURL + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var badToken authutils.AccessTokenResponse + err = json.Unmarshal(resp.Body(), &badToken) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", badToken.AccessToken)). + Post(baseURL + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) +} + +func TestOpenIDMiddleware(t *testing.T) { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + defaultVal := true + + conf := config.New() + conf.HTTP.Port = port + + testCases := []struct { + testCaseName string + address string + externalURL string + }{ + { + address: "0.0.0.0", + externalURL: fmt.Sprintf("http://%s", net.JoinHostPort(conf.HTTP.Address, conf.HTTP.Port)), + testCaseName: "with ExternalURL provided in config", + }, + { + address: "127.0.0.1", + externalURL: "", + testCaseName: "without ExternalURL provided in config", + }, + } + + // need a username different than ldap one, to test both logic + content := fmt.Sprintf("%s:$2y$05$hlbSXDp6hzDLu6VwACS39ORvVRpr3OMR4RlJ31jtlaOEGnPjKZI1m\n", htpasswdUsername) + htpasswdPath := test.MakeHtpasswdFileFromString(content) + + defer os.Remove(htpasswdPath) + + ldapServer := newTestLDAPServer() + port = test.GetFreePort() + + ldapPort, err := strconv.Atoi(port) + if err != nil { + panic(err) + } + + ldapServer.Start(ldapPort) + defer ldapServer.Stop() + + mockOIDCServer, err := authutils.MockOIDCRun() + if err != nil { + panic(err) + } + + defer func() { + err := mockOIDCServer.Shutdown() + if err != nil { + panic(err) + } + }() + + mockOIDCConfig := mockOIDCServer.Config() + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + LDAP: &config.LDAPConfig{ + Insecure: true, + Address: LDAPAddress, + Port: ldapPort, + BindDN: LDAPBindDN, + BindPassword: LDAPBindPassword, + BaseDN: LDAPBaseDN, + UserAttribute: "uid", + }, + OpenID: &config.OpenIDConfig{ + Providers: map[string]config.OpenIDProviderConfig{ + "oidc": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"openid", "email"}, + }, + // just for the constructor coverage + "github": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"openid", "email"}, + }, + }, + }, + } + + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + // UI is enabled because we also want to test access on the mgmt route + uiConfig := &extconf.UIConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + UI: uiConfig, + } + + ctlr := api.NewController(conf) + + for _, testcase := range testCases { + t.Run(testcase.testCaseName, func(t *testing.T) { + Convey("make controller", t, func() { + dir := t.TempDir() + + ctlr.Config.Storage.RootDirectory = dir + ctlr.Config.HTTP.ExternalURL = testcase.externalURL + ctlr.Config.HTTP.Address = testcase.address + cm := test.NewControllerManager(ctlr) + + cm.StartServer() + defer cm.StopServer() + test.WaitTillServerReady(baseURL) + + Convey("browser client requests", func() { + Convey("login with no provider supplied", func() { + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "unknown"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) + + //nolint: dupl + Convey("make sure sessions are not used without UI header value", func() { + sessionsNo, err := getNumberOfSessions(conf.Storage.RootDirectory) + So(err, ShouldBeNil) + So(sessionsNo, ShouldEqual, 0) + + client := resty.New() + + // without header should not create session + resp, err := client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + sessionsNo, err = getNumberOfSessions(conf.Storage.RootDirectory) + So(err, ShouldBeNil) + So(sessionsNo, ShouldEqual, 0) + + client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + + resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + sessionsNo, err = getNumberOfSessions(conf.Storage.RootDirectory) + So(err, ShouldBeNil) + So(sessionsNo, ShouldEqual, 1) + + // set cookies + client.SetCookies(resp.Cookies()) + + // should get same cookie + resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + sessionsNo, err = getNumberOfSessions(conf.Storage.RootDirectory) + So(err, ShouldBeNil) + So(sessionsNo, ShouldEqual, 1) + + resp, err = client.R(). + SetBasicAuth(htpasswdUsername, passphrase). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session, without credentials, (added to client after previous request) + resp, err = client.R(). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + sessionsNo, err = getNumberOfSessions(conf.Storage.RootDirectory) + So(err, ShouldBeNil) + So(sessionsNo, ShouldEqual, 1) + }) + + Convey("login with openid and get catalog with session", func() { + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + + Convey("with callback_ui value provided", func() { + // first login user + resp, err := client.R(). + SetQueryParam("provider", "oidc"). + SetQueryParam("callback_ui", baseURL+"/v2/"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) + + // first login user + resp, err := client.R(). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // logout with options method for coverage + resp, err = client.R(). + Options(baseURL + constants.LogoutPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + + // logout user + resp, err = client.R(). + Post(baseURL + constants.LogoutPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // calling endpoint should fail with unauthorized access + resp, err = client.R(). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + //nolint: dupl + Convey("login with basic auth(htpasswd) and get catalog with session", func() { + client := resty.New() + client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + + // without creds, should get access error + resp, err := client.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + var e apiErr.Error + err = json.Unmarshal(resp.Body(), &e) + So(err, ShouldBeNil) + + // first login user + // with creds, should get expected status code + resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R(). + SetBasicAuth(htpasswdUsername, passphrase). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session, without credentials, (added to client after previous request) + resp, err = client.R(). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R(). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // logout user + resp, err = client.R(). + Post(baseURL + constants.LogoutPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // calling endpoint should fail with unauthorized access + resp, err = client.R(). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + //nolint: dupl + Convey("login with ldap and get catalog", func() { + client := resty.New() + client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + + // without creds, should get access error + resp, err := client.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + var e apiErr.Error + err = json.Unmarshal(resp.Body(), &e) + So(err, ShouldBeNil) + + // first login user + // with creds, should get expected status code + resp, err = client.R().SetBasicAuth(username, passphrase).Get(baseURL) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R().SetBasicAuth(username, passphrase).Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R(). + SetBasicAuth(username, passphrase). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session, without credentials, (added to client after previous request) + resp, err = client.R(). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R(). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // logout user + resp, err = client.R(). + Post(baseURL + constants.LogoutPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // calling endpoint should fail with unauthorized access + resp, err = client.R(). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("unauthenticated catalog request", func() { + client := resty.New() + + // mgmt should work both unauthenticated and authenticated + resp, err := client.R(). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // call endpoint without session + resp, err = client.R(). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + }) + }) + }) + } +} + +func TestIsOpenIDEnabled(t *testing.T) { + Convey("make oidc server", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + + mockOIDCServer, err := authutils.MockOIDCRun() + if err != nil { + panic(err) + } + + defer func() { + err := mockOIDCServer.Shutdown() + if err != nil { + panic(err) + } + }() + + rootDir := t.TempDir() + + Convey("Only OAuth2 provided", func() { + mockOIDCConfig := mockOIDCServer.Config() + conf.HTTP.Auth = &config.AuthConfig{ + OpenID: &config.OpenIDConfig{ + Providers: map[string]config.OpenIDProviderConfig{ + "github": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"email", "groups"}, + }, + }, + }, + } + + ctlr := api.NewController(conf) + + ctlr.Config.Storage.RootDirectory = rootDir + + cm := test.NewControllerManager(ctlr) + + cm.StartServer() + defer cm.StopServer() + test.WaitTillServerReady(baseURL) + + resp, err := resty.R(). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("Unsupported provider", func() { + mockOIDCConfig := mockOIDCServer.Config() + conf.HTTP.Auth = &config.AuthConfig{ + OpenID: &config.OpenIDConfig{ + Providers: map[string]config.OpenIDProviderConfig{ + "invalidProvider": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"email", "groups"}, + }, + }, + }, + } + + ctlr := api.NewController(conf) + + ctlr.Config.Storage.RootDirectory = rootDir + + cm := test.NewControllerManager(ctlr) + + cm.StartServer() + defer cm.StopServer() + test.WaitTillServerReady(baseURL) + + // it will work because we have an invalid provider, and no other authn enabled, so no authn enabled + // normally an invalid provider will exit with error in cli validations + resp, err := resty.R(). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) + }) +} + +func TestAuthnSessionErrors(t *testing.T) { + Convey("make controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + defaultVal := true + + conf := config.New() + conf.HTTP.Port = port + invalidSessionID := "sessionID" + + // need a username different than ldap one, to test both logic + content := fmt.Sprintf("%s:$2y$05$hlbSXDp6hzDLu6VwACS39ORvVRpr3OMR4RlJ31jtlaOEGnPjKZI1m\n", htpasswdUsername) + + htpasswdPath := test.MakeHtpasswdFileFromString(content) + defer os.Remove(htpasswdPath) + + ldapServer := newTestLDAPServer() + port = test.GetFreePort() + + ldapPort, err := strconv.Atoi(port) + if err != nil { + panic(err) + } + + ldapServer.Start(ldapPort) + defer ldapServer.Stop() + + mockOIDCServer, err := authutils.MockOIDCRun() + if err != nil { + panic(err) + } + + defer func() { + err := mockOIDCServer.Shutdown() + if err != nil { + panic(err) + } + }() + + rootDir := t.TempDir() + + mockOIDCConfig := mockOIDCServer.Config() + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + LDAP: &config.LDAPConfig{ + Insecure: true, + Address: LDAPAddress, + Port: ldapPort, + BindDN: LDAPBindDN, + BindPassword: LDAPBindPassword, + BaseDN: LDAPBaseDN, + UserAttribute: "uid", + }, + OpenID: &config.OpenIDConfig{ + Providers: map[string]config.OpenIDProviderConfig{ + "oidc": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"email", "groups"}, + }, + }, + }, + } + + uiConfig := &extconf.UIConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + conf.Extensions = &extconf.ExtensionConfig{ + UI: uiConfig, + Search: searchConfig, + } + + ctlr := api.NewController(conf) + + ctlr.Config.Storage.RootDirectory = rootDir + + cm := test.NewControllerManager(ctlr) + + cm.StartServer() + defer cm.StopServer() + test.WaitTillServerReady(baseURL) + + Convey("trigger basic authn middle(htpasswd) error", func() { + client := resty.New() + + ctlr.MetaDB = mocks.MetaDBMock{ + SetUserGroupsFn: func(ctx context.Context, groups []string) error { + return ErrUnexpectedError + }, + } + + resp, err := client.R(). + SetBasicAuth(htpasswdUsername, passphrase). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + + Convey("trigger basic authn middle(ldap) error", func() { + client := resty.New() + + ctlr.MetaDB = mocks.MetaDBMock{ + SetUserGroupsFn: func(ctx context.Context, groups []string) error { + return ErrUnexpectedError + }, + } + + resp, err := client.R(). + SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + + Convey("trigger updateUserData error", func() { + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + ctlr.MetaDB = mocks.MetaDBMock{ + SetUserGroupsFn: func(ctx context.Context, groups []string) error { + return ErrUnexpectedError + }, + } + + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + + Convey("trigger session middle metaDB errors", func() { + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + user := mockoidc.DefaultUser() + user.Groups = []string{"group1", "group2"} + + mockOIDCServer.QueueUser(user) + + ctlr.MetaDB = mocks.MetaDBMock{} + + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + Convey("trigger session middle error internal server error", func() { + cookies := resp.Cookies() + + client.SetCookies(cookies) + + ctlr.MetaDB = mocks.MetaDBMock{ + GetUserGroupsFn: func(ctx context.Context) ([]string, error) { + return []string{}, ErrUnexpectedError + }, + } + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + + Convey("trigger session middle error GetUserGroups not found", func() { + cookies := resp.Cookies() + + client.SetCookies(cookies) + + ctlr.MetaDB = mocks.MetaDBMock{ + GetUserGroupsFn: func(ctx context.Context) ([]string, error) { + return []string{}, errors.ErrUserDataNotFound + }, + } + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + }) + + Convey("trigger no email error in routes(callback)", func() { + user := mockoidc.DefaultUser() + user.Email = "" + + mockOIDCServer.QueueUser(user) + + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + client.SetCookie(&http.Cookie{Name: "session"}) + + // call endpoint with session (added to client after previous request) + resp, err := client.R(). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("trigger session save error in routes(callback)", func() { + err := os.Chmod(rootDir, 0o000) + So(err, ShouldBeNil) + + defer func() { + err := os.Chmod(rootDir, storageConstants.DefaultDirPerms) + So(err, ShouldBeNil) + }() + + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + + Convey("trigger session save error in basicAuthn", func() { + err := os.Chmod(rootDir, 0o000) + So(err, ShouldBeNil) + + defer func() { + err := os.Chmod(rootDir, storageConstants.DefaultDirPerms) + So(err, ShouldBeNil) + }() + + client := resty.New() + client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + + // first htpasswd saveSessionLoggedUser() error + resp, err := client.R(). + SetBasicAuth(htpasswdUsername, passphrase). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + + // second ldap saveSessionLoggedUser() error + resp, err = client.R(). + SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + + Convey("trigger session middle errors", func() { + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + user := mockoidc.DefaultUser() + user.Groups = []string{"group1", "group2"} + + mockOIDCServer.QueueUser(user) + + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + Convey("trigger bad session encoding error in authn", func() { + cookies := resp.Cookies() + for _, cookie := range cookies { + if cookie.Name == "session" { + cookie.Value = "badSessionValue" + } + } + + client.SetCookies(cookies) + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("web request without cookies", func() { + client.SetCookie(&http.Cookie{}) + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("web request with userless cookie", func() { + // first get session + session, err := ctlr.CookieStore.Get(resp.RawResponse.Request, "session") + So(err, ShouldBeNil) + + session.ID = invalidSessionID + session.IsNew = false + session.Values["authStatus"] = true + + cookieStore, ok := ctlr.CookieStore.(*sessions.FilesystemStore) + So(ok, ShouldBeTrue) + + // first encode sessionID + encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, + cookieStore.Codecs...) + So(err, ShouldBeNil) + + // save cookie + cookie := sessions.NewCookie(session.Name(), encoded, session.Options) + client.SetCookie(cookie) + + // encode session values and save on disk + encoded, err = securecookie.EncodeMulti(session.Name(), session.Values, + cookieStore.Codecs...) + So(err, ShouldBeNil) + + filename := filepath.Join(rootDir, "_sessions", "session_"+session.ID) + + err = os.WriteFile(filename, []byte(encoded), 0o600) + So(err, ShouldBeNil) + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("web request with authStatus false cookie", func() { + // first get session + session, err := ctlr.CookieStore.Get(resp.RawResponse.Request, "session") + So(err, ShouldBeNil) + + session.ID = invalidSessionID + session.IsNew = false + session.Values["authStatus"] = false + session.Values["username"] = username + + cookieStore, ok := ctlr.CookieStore.(*sessions.FilesystemStore) + So(ok, ShouldBeTrue) + + // first encode sessionID + encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, + cookieStore.Codecs...) + So(err, ShouldBeNil) + + // save cookie + cookie := sessions.NewCookie(session.Name(), encoded, session.Options) + client.SetCookie(cookie) + + // encode session values and save on disk + encoded, err = securecookie.EncodeMulti(session.Name(), session.Values, + cookieStore.Codecs...) + So(err, ShouldBeNil) + + filename := filepath.Join(rootDir, "_sessions", "session_"+session.ID) + + err = os.WriteFile(filename, []byte(encoded), 0o600) + So(err, ShouldBeNil) + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + }) + }) +} + +func TestAuthnMetaDBErrors(t *testing.T) { + Convey("make controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) + + mockOIDCServer, err := authutils.MockOIDCRun() + if err != nil { + panic(err) + } + + defer func() { + err := mockOIDCServer.Shutdown() + if err != nil { + panic(err) + } + }() + + rootDir := t.TempDir() + + mockOIDCConfig := mockOIDCServer.Config() + 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"}, + }, + }, + }, + } + + ctlr := api.NewController(conf) + + ctlr.Config.Storage.RootDirectory = rootDir + + cm := test.NewControllerManager(ctlr) + + cm.StartServer() + defer cm.StopServer() + test.WaitTillServerReady(baseURL) + + Convey("trigger basic authn middle(htpasswd) error", func() { + client := resty.New() + + ctlr.MetaDB = mocks.MetaDBMock{ + SetUserGroupsFn: func(ctx context.Context, groups []string) error { + return ErrUnexpectedError + }, + } + + resp, err := client.R(). + SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + + Convey("trigger session middle metaDB errors", func() { + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + user := mockoidc.DefaultUser() + user.Groups = []string{"group1", "group2"} + + mockOIDCServer.QueueUser(user) + + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + Convey("trigger session middle error", func() { + cookies := resp.Cookies() + + client.SetCookies(cookies) + + ctlr.MetaDB = mocks.MetaDBMock{ + GetUserGroupsFn: func(ctx context.Context) ([]string, error) { + return []string{}, ErrUnexpectedError + }, + } + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + }) + }) +} + +func TestGetUsername(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) + defer os.Remove(htpasswdPath) + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + + dir := t.TempDir() + ctlr := makeController(conf, dir) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + resp, err := resty.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // test base64 encode + resp, err = resty.R().SetHeader("Authorization", "Basic should fail").Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // test "username:password" encoding + resp, err = resty.R().SetHeader("Authorization", "Basic dGVzdA==").Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // failed parsing authorization header + resp, err = resty.R().SetHeader("Authorization", "Basic ").Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + var e apiErr.Error + err = json.Unmarshal(resp.Body(), &e) + So(err, ShouldBeNil) + + resp, err = resty.R().SetBasicAuth(username, passphrase).Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) +} + +func TestAllowedMethodsHeaderAPIKey(t *testing.T) { + defaultVal := true + + Convey("Test http options response", t, func() { + conf := config.New() + port := test.GetFreePort() + conf.HTTP.Port = port + conf.HTTP.Auth.APIKey = defaultVal + baseURL := test.GetBaseURL(port) + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + ctrlManager := test.NewControllerManager(ctlr) + + ctrlManager.StartAndWait(port) + defer ctrlManager.StopServer() + + resp, _ := resty.R().Options(baseURL + constants.APIKeyPath) + So(resp, ShouldNotBeNil) + So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,POST,DELETE,OPTIONS") + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + }) +} + +func TestAPIKeys(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) + + mockOIDCServer, err := authutils.MockOIDCRun() + 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", "groups"}, + }, + }, + }, + APIKey: defaultVal, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{} + + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal + + ctlr := api.NewController(conf) + dir := t.TempDir() + + ctlr.Config.Storage.RootDirectory = dir + + cm := test.NewControllerManager(ctlr) + + cm.StartServer() + defer cm.StopServer() + test.WaitTillServerReady(baseURL) + + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + } + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) + + Convey("API key retrieved with basic auth", func() { + resp, err := resty.R(). + SetBody(reqBody). + SetBasicAuth("test", "test"). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + user := mockoidc.DefaultUser() + + // get API key and email from apikey route response + var apiKeyResponse apiKeyResponse + err = json.Unmarshal(resp.Body(), &apiKeyResponse) + So(err, ShouldBeNil) + + email := user.Email + So(email, ShouldNotBeEmpty) + + resp, err = resty.R(). + SetBasicAuth("test", apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get API key list with basic auth + resp, err = resty.R(). + SetBasicAuth("test", "test"). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var apiKeyListResponse apiKeyListResponse + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + + // add another one + resp, err = resty.R(). + SetBody(reqBody). + SetBasicAuth("test", "test"). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + err = json.Unmarshal(resp.Body(), &apiKeyResponse) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetBasicAuth("test", apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get API key list with api key auth + resp, err = resty.R(). + SetBasicAuth("test", apiKeyResponse.APIKey). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 2) + }) + + Convey("API key retrieved with openID and with no expire", func() { + client := resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + + cookies := resp.Cookies() + + // call endpoint without session + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + client.SetCookies(cookies) + + // call endpoint with session ( added to client after previous request) + resp, err = client.R(). + SetBody(reqBody). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + user := mockoidc.DefaultUser() + + // get API key and email from apikey route response + var apiKeyResponse apiKeyResponse + err = json.Unmarshal(resp.Body(), &apiKeyResponse) + So(err, ShouldBeNil) + + email := user.Email + So(email, ShouldNotBeEmpty) + + // get API key list + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var apiKeyListResponse apiKeyListResponse + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // trigger errors + ctlr.MetaDB = mocks.MetaDBMock{ + GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) { + return "", ErrUnexpectedError + }, + } + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + + ctlr.MetaDB = mocks.MetaDBMock{ + GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) { + return user.Email, nil + }, + GetUserGroupsFn: func(ctx context.Context) ([]string, error) { + return []string{}, ErrUnexpectedError + }, + } + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + + ctlr.MetaDB = mocks.MetaDBMock{ + GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) { + return user.Email, nil + }, + UpdateUserAPIKeyLastUsedFn: func(ctx context.Context, hashedKey string) error { + return ErrUnexpectedError + }, + } + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + + client = resty.New() + + // call endpoint without session + resp, err = client.R(). + SetBody(reqBody). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("API key retrieved with openID and with long expire", func() { + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + ExpirationDate: time.Now().Add(time.Hour).Local().Format(constants.APIKeyTimeFormat), + } + + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) + + client := resty.New() + + // mgmt should work both unauthenticated and authenticated + resp, err := client.R().Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // first login user + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session ( added to client after previous request) + resp, err = client.R(). + SetBody(reqBody). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + var apiKeyResponse apiKeyResponse + err = json.Unmarshal(resp.Body(), &apiKeyResponse) + So(err, ShouldBeNil) + + // get API key list + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var apiKeyListResponse apiKeyListResponse + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + + user := mockoidc.DefaultUser() + email := user.Email + So(email, ShouldNotBeEmpty) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // auth with API key + // we need new client without session cookie set + client = resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get API key list + resp, err = resty.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + + // invalid api keys + resp, err = client.R(). + SetBasicAuth("invalidEmail", apiKeyResponse.APIKey). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + resp, err = client.R(). + SetBasicAuth(email, "noprefixAPIKey"). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + resp, err = client.R(). + SetBasicAuth(email, "zak_notworkingAPIKey"). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + userAc := reqCtx.NewUserAccessControl() + userAc.SetUsername(email) + ctx := userAc.DeriveContext(context.Background()) + + err = ctlr.MetaDB.DeleteUserData(ctx) + So(err, ShouldBeNil) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + + client = resty.New() + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + // without creds should work + resp, err = client.R().Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // login again + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + client.SetCookies(resp.Cookies()) + + resp, err = client.R(). + SetBody(reqBody). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + err = json.Unmarshal(resp.Body(), &apiKeyResponse) + So(err, ShouldBeNil) + + // should work with session + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // should work with api key + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + constants.FullMgmt) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyResponse) + So(err, ShouldBeNil) + + // delete api key + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("id", apiKeyResponse.UUID). + Delete(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // apiKey removed, should get 401 + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Delete(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get API key list + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 0) + + resp, err = client.R(). + SetBasicAuth("test", "test"). + SetQueryParam("id", apiKeyResponse.UUID). + Delete(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // unsupported method + resp, err = client.R(). + Put(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + }) + + Convey("API key retrieved with openID and with short expire", func() { + expirationDate := time.Now().Add(1 * time.Second).Local().Round(time.Second) + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + ExpirationDate: expirationDate.Format(constants.APIKeyTimeFormat), + } + + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) + + client := resty.New() + + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + client.SetCookies(resp.Cookies()) + + // call endpoint with session (added to client after previous request) + resp, err = client.R(). + SetBody(reqBody). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Post(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + var apiKeyResponse apiKeyResponse + err = json.Unmarshal(resp.Body(), &apiKeyResponse) + So(err, ShouldBeNil) + + user := mockoidc.DefaultUser() + email := user.Email + So(email, ShouldNotBeEmpty) + + // get API key list + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var apiKeyListResponse apiKeyListResponse + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + So(apiKeyListResponse.APIKeys[0].IsExpired, ShouldEqual, false) + So(apiKeyListResponse.APIKeys[0].ExpirationDate.Equal(expirationDate), ShouldBeTrue) resp, err = client.R(). SetBasicAuth(email, apiKeyResponse.APIKey). @@ -287,69 +2762,106 @@ func TestAPIKeys(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) - // trigger errors - ctlr.MetaDB = mocks.MetaDBMock{ - GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) { - return "", ErrUnexpectedError - }, - } + // sleep past expire time + time.Sleep(1500 * time.Millisecond) resp, err = client.R(). SetBasicAuth(email, apiKeyResponse.APIKey). Get(baseURL + "/v2/_catalog") So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - ctlr.MetaDB = mocks.MetaDBMock{ - GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) { - return user.Email, nil - }, - GetUserGroupsFn: func(ctx context.Context) ([]string, error) { - return []string{}, ErrUnexpectedError - }, - } + // again for coverage + resp, err = client.R(). + SetBasicAuth(email, apiKeyResponse.APIKey). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // get API key list with session authn + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) + So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) + So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) + So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) + So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) + So(apiKeyListResponse.APIKeys[0].IsExpired, ShouldEqual, true) + So(apiKeyListResponse.APIKeys[0].ExpirationDate.Equal(expirationDate), ShouldBeTrue) + + // delete expired api key + resp, err = client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("id", apiKeyResponse.UUID). + Delete(baseURL + constants.APIKeyPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // get API key list with session authn resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - ctlr.MetaDB = mocks.MetaDBMock{ - GetUserAPIKeyInfoFn: func(hashedKey string) (string, error) { - return user.Email, nil - }, - UpdateUserAPIKeyLastUsedFn: func(ctx context.Context, hashedKey string) error { - return ErrUnexpectedError - }, + err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + So(err, ShouldBeNil) + So(len(apiKeyListResponse.APIKeys), ShouldEqual, 0) + }) + + Convey("Create API key with expirationDate before actual date", func() { + expirationDate := time.Now().Add(-5 * time.Second).Local().Round(time.Second) + payload := api.APIKeyPayload{ + Label: "test", + Scopes: []string{"test"}, + ExpirationDate: expirationDate.Format(constants.APIKeyTimeFormat), } - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") + reqBody, err := json.Marshal(payload) + So(err, ShouldBeNil) + + client := resty.New() + + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - client = resty.New() + client.SetCookies(resp.Cookies()) - // call endpoint without session + // call endpoint with session ( added to client after previous request) resp, err = client.R(). SetBody(reqBody). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) }) - Convey("API key retrieved with openID and with long expire", func() { + Convey("Create API key with unparsable expirationDate", func() { + expirationDate := time.Now().Add(-5 * time.Second).Local().Round(time.Second) payload := api.APIKeyPayload{ Label: "test", Scopes: []string{"test"}, - ExpirationDate: time.Now().Add(time.Hour).Local().Format(constants.APIKeyTimeFormat), + ExpirationDate: expirationDate.Format(time.RFC1123Z), } reqBody, err := json.Marshal(payload) @@ -357,15 +2869,9 @@ func TestAPIKeys(t *testing.T) { client := resty.New() - // mgmt should work both unauthenticated and authenticated - resp, err := client.R().Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) // first login user - resp, err = client.R(). + resp, err := client.R(). SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). SetQueryParam("provider", "oidc"). Get(baseURL + constants.LoginPath) @@ -382,560 +2888,890 @@ func TestAPIKeys(t *testing.T) { Post(baseURL + constants.APIKeyPath) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) - var apiKeyResponse apiKeyResponse - err = json.Unmarshal(resp.Body(), &apiKeyResponse) - So(err, ShouldBeNil) + Convey("Test error handling when API Key handler reads the request body", func() { + request, _ := http.NewRequestWithContext(context.TODO(), + http.MethodPost, "baseURL", errReader(0)) + response := httptest.NewRecorder() - // get API key list - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + rthdlr := api.NewRouteHandler(ctlr) + rthdlr.CreateAPIKey(response, request) - var apiKeyListResponse apiKeyListResponse + resp := response.Result() + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + }) + }) +} - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) - So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) - So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) - So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) - So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) - So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) - So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) +func TestAPIKeysOpenDBError(t *testing.T) { + Convey("Test API keys - unable to create database", t, func() { + conf := config.New() + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) - user := mockoidc.DefaultUser() - email := user.Email - So(email, ShouldNotBeEmpty) + mockOIDCServer, err := authutils.MockOIDCRun() + if err != nil { + panic(err) + } - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + defer func() { + err := mockOIDCServer.Shutdown() + if err != nil { + panic(err) + } + }() - // auth with API key - // we need new client without session cookie set - client = resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + mockOIDCConfig := mockOIDCServer.Config() + defaultVal := true - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + OpenID: &config.OpenIDConfig{ + Providers: map[string]config.OpenIDProviderConfig{ + "oidc": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"openid", "email"}, + }, + }, + }, - // get API key list - resp, err = resty.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + APIKey: defaultVal, + } - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) - So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) + ctlr := api.NewController(conf) + dir := t.TempDir() - // invalid api keys - resp, err = client.R(). - SetBasicAuth("invalidEmail", apiKeyResponse.APIKey). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + err = os.Chmod(dir, 0o000) + So(err, ShouldBeNil) + + ctlr.Config.Storage.RootDirectory = dir + cm := test.NewControllerManager(ctlr) + + So(func() { + cm.StartServer() + }, ShouldPanic) + }) +} + +func TestAPIKeysGeneratorErrors(t *testing.T) { + Convey("Test API keys - unable to generate API keys and API Key IDs", t, func() { + log := log.NewLogger("debug", "") + + apiKey, apiKeyID, err := api.GenerateAPIKey(guuid.DefaultGenerator, log) + So(err, ShouldBeNil) + So(apiKey, ShouldNotEqual, "") + So(apiKeyID, ShouldNotEqual, "") + + generator := &mockUUIDGenerator{ + guuid.DefaultGenerator, 0, 0, + } + + apiKey, apiKeyID, err = api.GenerateAPIKey(generator, log) + So(err, ShouldNotBeNil) + So(apiKey, ShouldEqual, "") + So(apiKeyID, ShouldEqual, "") + + generator = &mockUUIDGenerator{ + guuid.DefaultGenerator, 1, 0, + } + + apiKey, apiKeyID, err = api.GenerateAPIKey(generator, log) + So(err, ShouldNotBeNil) + So(apiKey, ShouldEqual, "") + So(apiKeyID, ShouldEqual, "") + }) +} + +func TestHTTPReadOnly(t *testing.T) { + Convey("Single cred", t, func() { + singleCredtests := []string{} + user := ALICE + password := ALICE + singleCredtests = append(singleCredtests, getCredString(user, password)) + singleCredtests = append(singleCredtests, getCredString(user, password)+"\n") + + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + for _, testString := range singleCredtests { + func() { + conf := config.New() + conf.HTTP.Port = port + // enable read-only mode + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + DefaultPolicy: []string{"read"}, + }, + }, + } + + htpasswdPath := test.MakeHtpasswdFileFromString(testString) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + ctlr := makeController(conf, t.TempDir()) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // with creds, should get expected status code + resp, _ := resty.R().SetBasicAuth(user, password).Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with creds, any modifications should still fail on read-only mode + resp, err := resty.R().SetBasicAuth(user, password). + Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // with invalid creds, it should fail + resp, _ = resty.R().SetBasicAuth("chuck", "chuck").Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }() + } + }) +} + +type mockUUIDGenerator struct { + guuid.Generator + succeedAttempts int + attemptCount int +} + +func (gen *mockUUIDGenerator) NewV4() ( + guuid.UUID, error, +) { + defer func() { + gen.attemptCount += 1 + }() + + if gen.attemptCount >= gen.succeedAttempts { + return guuid.UUID{}, ErrUnexpectedError + } + + return guuid.DefaultGenerator.NewV4() +} + +type errReader int + +func (errReader) Read(p []byte) (int, error) { + return 0, fmt.Errorf("test error") //nolint:goerr113 +} + +func TestSearchRoutes(t *testing.T) { + Convey("Upload image for test", t, func(c C) { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + tempDir := t.TempDir() + + ctlr := makeController(conf, tempDir) + cm := test.NewControllerManager(ctlr) - resp, err = client.R(). - SetBasicAuth(email, "noprefixAPIKey"). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + cm.StartAndWait(port) + defer cm.StopServer() - resp, err = client.R(). - SetBasicAuth(email, "zak_notworkingAPIKey"). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + repoName := "testrepo" //nolint:goconst + inaccessibleRepo := "inaccessible" - userAc := reqCtx.NewUserAccessControl() - userAc.SetUsername(email) - ctx := userAc.DeriveContext(context.Background()) + cfg, layers, manifest, err := deprecated.GetImageComponents(10000) //nolint:staticcheck + So(err, ShouldBeNil) - err = ctlr.MetaDB.DeleteUserData(ctx) - So(err, ShouldBeNil) + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, "latest") - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + So(err, ShouldBeNil) - client = resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + // data for the inaccessible repo + cfg, layers, manifest, err = deprecated.GetImageComponents(10000) //nolint:staticcheck + So(err, ShouldBeNil) - // without creds should work - resp, err = client.R().Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, inaccessibleRepo, "latest") - // login again - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + So(err, ShouldBeNil) - client.SetCookies(resp.Cookies()) + Convey("GlobalSearch with authz enabled", func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + user1 := "test" + password1 := "test" + testString1 := getCredString(user1, password1) + htpasswdPath := test.MakeHtpasswdFileFromString(testString1) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } - resp, err = client.R(). - SetBody(reqBody). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + conf.HTTP.Port = port - err = json.Unmarshal(resp.Body(), &apiKeyResponse) - So(err, ShouldBeNil) + defaultVal := true - // should work with session - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + } - // should work with api key - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + repoName: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{user1}, + Actions: []string{"read", "create"}, + }, + }, + DefaultPolicy: []string{}, + }, + inaccessibleRepo: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{user1}, + Actions: []string{"create"}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") + ctlr := makeController(conf, tempDir) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + cfg, layers, manifest, err := deprecated.GetImageComponents(10000) //nolint:staticcheck So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = json.Unmarshal(resp.Body(), &apiKeyResponse) + err = UploadImageWithBasicAuth( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, "latest", + user1, password1) So(err, ShouldBeNil) - // delete api key - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("id", apiKeyResponse.UUID). - Delete(baseURL + constants.APIKeyPath) + // data for the inaccessible repo + cfg, layers, manifest, err = deprecated.GetImageComponents(10000) //nolint:staticcheck So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - // apiKey removed, should get 401 - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") + err = UploadImageWithBasicAuth( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, inaccessibleRepo, "latest", + user1, password1) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Delete(baseURL + constants.APIKeyPath) + query := ` + { + GlobalSearch(query:"testrepo"){ + Repos { + Name + NewestImage { + RepoName + Tag + } + } + } + }` + resp, err := resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.FullSearchPrefix + + "?query=" + url.QueryEscape(query)) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + So(resp.StatusCode(), ShouldEqual, 200) + So(string(resp.Body()), ShouldContainSubstring, repoName) + So(string(resp.Body()), ShouldNotContainSubstring, inaccessibleRepo) - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") + resp, err = resty.R().Get(baseURL + constants.FullSearchPrefix + "?query=" + url.QueryEscape(query)) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + repoName: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{user1}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + }, + inaccessibleRepo: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } - // get API key list - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.APIKeyPath) + // authenticated, but no access to resource + resp, err = resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.FullSearchPrefix + + "?query=" + url.QueryEscape(query)) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(string(resp.Body()), ShouldNotContainSubstring, repoName) + So(string(resp.Body()), ShouldNotContainSubstring, inaccessibleRepo) + }) - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) - So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 0) + Convey("Testing group permissions", func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + user1 := "test1" + password1 := "test1" + group1 := "testgroup3" + testString1 := getCredString(user1, password1) + htpasswdPath := test.MakeHtpasswdFileFromString(testString1) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } - resp, err = client.R(). - SetBasicAuth("test", "test"). - SetQueryParam("id", apiKeyResponse.UUID). - Delete(baseURL + constants.APIKeyPath) + conf.HTTP.Port = port + + defaultVal := true + + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + } + + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + group1: { + Users: []string{user1}, + }, + }, + Repositories: config.Repositories{ + repoName: config.PolicyGroup{ + Policies: []config.Policy{ + { + Groups: []string{group1}, + Actions: []string{"read", "create"}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } + + ctlr := makeController(conf, tempDir) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + img := CreateRandomImage() + + err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - // unsupported method - resp, err = client.R(). - Put(baseURL + constants.APIKeyPath) + query := ` + { + GlobalSearch(query:"testrepo"){ + Repos { + Name + NewestImage { + RepoName + Tag + } + } + } + }` + resp, err := resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.FullSearchPrefix + + "?query=" + url.QueryEscape(query)) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + So(resp.StatusCode(), ShouldEqual, 200) }) - Convey("API key retrieved with openID and with short expire", func() { - expirationDate := time.Now().Add(1 * time.Second).Local().Round(time.Second) - payload := api.APIKeyPayload{ - Label: "test", - Scopes: []string{"test"}, - ExpirationDate: expirationDate.Format(constants.APIKeyTimeFormat), + Convey("Testing group permissions when the user is part of more groups with different permissions", func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + user1 := "test2" + password1 := "test2" + group1 := "testgroup1" + group2 := "secondtestgroup" + testString1 := getCredString(user1, password1) + htpasswdPath := test.MakeHtpasswdFileFromString(testString1) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, } - reqBody, err := json.Marshal(payload) - So(err, ShouldBeNil) + conf.HTTP.Port = port - client := resty.New() + defaultVal := true - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } - client.SetCookies(resp.Cookies()) + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + } - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - SetBody(reqBody). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + group1: { + Users: []string{user1}, + }, + }, + Repositories: config.Repositories{ + repoName: config.PolicyGroup{ + Policies: []config.Policy{ + { + Groups: []string{group1}, + Actions: []string{"delete"}, + }, + { + Groups: []string{group2}, + Actions: []string{"read", "create"}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } - var apiKeyResponse apiKeyResponse - err = json.Unmarshal(resp.Body(), &apiKeyResponse) - So(err, ShouldBeNil) + ctlr := makeController(conf, tempDir) - user := mockoidc.DefaultUser() - email := user.Email - So(email, ShouldNotBeEmpty) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - // get API key list - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + img := CreateRandomImage() - var apiKeyListResponse apiKeyListResponse + err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) + So(err, ShouldNotBeNil) + }) - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) - So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) - So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) - So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) - So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) - So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) - So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) - So(apiKeyListResponse.APIKeys[0].IsExpired, ShouldEqual, false) - So(apiKeyListResponse.APIKeys[0].ExpirationDate.Equal(expirationDate), ShouldBeTrue) + Convey("Testing group permissions when group has less permissions than user", func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + user1 := "test3" + password1 := "test3" + group1 := "testgroup" + testString1 := getCredString(user1, password1) + htpasswdPath := test.MakeHtpasswdFileFromString(testString1) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + conf.HTTP.Port = port - // sleep past expire time - time.Sleep(1500 * time.Millisecond) + defaultVal := true - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } - // again for coverage - resp, err = client.R(). - SetBasicAuth(email, apiKeyResponse.APIKey). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + } - // get API key list with session authn - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + group1: { + Users: []string{user1}, + }, + }, + Repositories: config.Repositories{ + repoName: config.PolicyGroup{ + Policies: []config.Policy{ + { + Groups: []string{group1}, + Actions: []string{"delete"}, + }, + { + Users: []string{user1}, + Actions: []string{"read", "create", "delete"}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) - So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 1) - So(apiKeyListResponse.APIKeys[0].CreatedAt, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatedAt) - So(apiKeyListResponse.APIKeys[0].CreatorUA, ShouldEqual, apiKeyResponse.APIKeyDetails.CreatorUA) - So(apiKeyListResponse.APIKeys[0].Label, ShouldEqual, apiKeyResponse.APIKeyDetails.Label) - So(apiKeyListResponse.APIKeys[0].Scopes, ShouldEqual, apiKeyResponse.APIKeyDetails.Scopes) - So(apiKeyListResponse.APIKeys[0].UUID, ShouldEqual, apiKeyResponse.APIKeyDetails.UUID) - So(apiKeyListResponse.APIKeys[0].IsExpired, ShouldEqual, true) - So(apiKeyListResponse.APIKeys[0].ExpirationDate.Equal(expirationDate), ShouldBeTrue) + ctlr := makeController(conf, tempDir) - // delete expired api key - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("id", apiKeyResponse.UUID). - Delete(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - // get API key list with session authn - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + img := CreateRandomImage() - err = json.Unmarshal(resp.Body(), &apiKeyListResponse) + err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) So(err, ShouldBeNil) - So(len(apiKeyListResponse.APIKeys), ShouldEqual, 0) }) - Convey("Create API key with expirationDate before actual date", func() { - expirationDate := time.Now().Add(-5 * time.Second).Local().Round(time.Second) - payload := api.APIKeyPayload{ - Label: "test", - Scopes: []string{"test"}, - ExpirationDate: expirationDate.Format(constants.APIKeyTimeFormat), + Convey("Testing group permissions when user has less permissions than group", func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + user1 := "test4" + password1 := "test4" + group1 := "testgroup1" + testString1 := getCredString(user1, password1) + htpasswdPath := test.MakeHtpasswdFileFromString(testString1) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, } - reqBody, err := json.Marshal(payload) - So(err, ShouldBeNil) + conf.HTTP.Port = port - client := resty.New() + defaultVal := true - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } - client.SetCookies(resp.Cookies()) + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + } - // call endpoint with session ( added to client after previous request) - resp, err = client.R(). - SetBody(reqBody). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.APIKeyPath) + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + group1: { + Users: []string{user1}, + }, + }, + Repositories: config.Repositories{ + repoName: config.PolicyGroup{ + Policies: []config.Policy{ + { + Groups: []string{group1}, + Actions: []string{"read", "create", "delete"}, + }, + { + Users: []string{user1}, + Actions: []string{"delete"}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } + + ctlr := makeController(conf, tempDir) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + img := CreateRandomImage() + + err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) }) - Convey("Create API key with unparsable expirationDate", func() { - expirationDate := time.Now().Add(-5 * time.Second).Local().Round(time.Second) - payload := api.APIKeyPayload{ - Label: "test", - Scopes: []string{"test"}, - ExpirationDate: expirationDate.Format(time.RFC1123Z), + Convey("Testing group permissions on admin policy", func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + user1 := "test5" + password1 := "test5" + group1 := "testgroup2" + testString1 := getCredString(user1, password1) + htpasswdPath := test.MakeHtpasswdFileFromString(testString1) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, } - reqBody, err := json.Marshal(payload) - So(err, ShouldBeNil) + conf.HTTP.Port = port - client := resty.New() + defaultVal := true - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } - client.SetCookies(resp.Cookies()) + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + } - // call endpoint with session ( added to client after previous request) - resp, err = client.R(). - SetBody(reqBody). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Post(baseURL + constants.APIKeyPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - }) + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + group1: { + Users: []string{user1}, + }, + }, + Repositories: config.Repositories{}, + AdminPolicy: config.Policy{ + Groups: []string{group1}, + Actions: []string{"read", "create"}, + }, + } - Convey("Test error handling when API Key handler reads the request body", func() { - request, _ := http.NewRequestWithContext(context.TODO(), - http.MethodPost, "baseURL", errReader(0)) - response := httptest.NewRecorder() + ctlr := makeController(conf, tempDir) - rthdlr := api.NewRouteHandler(ctlr) - rthdlr.CreateAPIKey(response, request) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - resp := response.Result() - defer resp.Body.Close() - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + img := CreateRandomImage() + + err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) + So(err, ShouldBeNil) }) - }) -} -func TestAPIKeysOpenDBError(t *testing.T) { - Convey("Test API keys - unable to create database", t, func() { - conf := config.New() - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) + Convey("Testing group permissions on anonymous policy", func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) - mockOIDCServer, err := authutils.MockOIDCRun() - if err != nil { - panic(err) - } + conf.HTTP.Port = port - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) - } - }() + defaultVal := true + group1 := group + user1 := username + password1 := passphrase - mockOIDCConfig := mockOIDCServer.Config() - defaultVal := true + testString1 := getCredString(user1, password1) + htpasswdPath := test.MakeHtpasswdFileFromString(testString1) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } - OpenID: &config.OpenIDConfig{ - Providers: map[string]config.OpenIDProviderConfig{ - "oidc": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"openid", "email"}, + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Groups: config.Groups{ + group1: { + Users: []string{user1}, }, }, - }, - - APIKey: defaultVal, - } + Repositories: config.Repositories{ + repoName: config.PolicyGroup{ + Policies: []config.Policy{ + { + Groups: []string{group1}, + Actions: []string{"read", "create", "delete"}, + }, + { + Users: []string{user1}, + Actions: []string{"delete"}, + }, + }, + DefaultPolicy: []string{}, + AnonymousPolicy: []string{"read", "create"}, + }, + }, + } - ctlr := api.NewController(conf) - dir := t.TempDir() + ctlr := makeController(conf, tempDir) - err = os.Chmod(dir, 0o000) - So(err, ShouldBeNil) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - ctlr.Config.Storage.RootDirectory = dir - cm := test.NewControllerManager(ctlr) + img := CreateRandomImage() - So(func() { - cm.StartServer() - }, ShouldPanic) + err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), "", "") + So(err, ShouldBeNil) + }) }) } -func TestAPIKeysGeneratorErrors(t *testing.T) { - Convey("Test API keys - unable to generate API keys and API Key IDs", t, func() { - log := log.NewLogger("debug", "") +func TestMetricsAuthentication(t *testing.T) { + Convey("test metrics without authentication and metrics enabled", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port - apiKey, apiKeyID, err := api.GenerateAPIKey(guuid.DefaultGenerator, log) - So(err, ShouldBeNil) - So(apiKey, ShouldNotEqual, "") - So(apiKeyID, ShouldNotEqual, "") + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() - generator := &mockUUIDGenerator{ - guuid.DefaultGenerator, 0, 0, + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // metrics endpoint not available + resp, err := resty.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + }) + Convey("test metrics without authentication and with metrics enabled", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + enabled := true + metricsConfig := &extconf.MetricsConfig{ + BaseConfig: extconf.BaseConfig{Enable: &enabled}, + Prometheus: &extconf.PrometheusConfig{Path: "/metrics"}, + } + conf.Extensions = &extconf.ExtensionConfig{ + Metrics: metricsConfig, } - apiKey, apiKeyID, err = api.GenerateAPIKey(generator, log) - So(err, ShouldNotBeNil) - So(apiKey, ShouldEqual, "") - So(apiKeyID, ShouldEqual, "") + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() - generator = &mockUUIDGenerator{ - guuid.DefaultGenerator, 1, 0, - } + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - apiKey, apiKeyID, err = api.GenerateAPIKey(generator, log) - So(err, ShouldNotBeNil) - So(apiKey, ShouldEqual, "") - So(apiKeyID, ShouldEqual, "") + // without auth set metrics endpoint is available + resp, err := resty.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) }) -} + Convey("test metrics with authentication and metrics enabled", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port -type mockUUIDGenerator struct { - guuid.Generator - succeedAttempts int - attemptCount int -} + credTests := fmt.Sprintf("%s\n%s\n", getCredString(username, passphrase), getCredString(metricsuser, metricspass)) + htpasswdPath := test.MakeHtpasswdFileFromString(credTests) -func (gen *mockUUIDGenerator) NewV4() ( - guuid.UUID, error, -) { - defer func() { - gen.attemptCount += 1 - }() + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } - if gen.attemptCount >= gen.succeedAttempts { - return guuid.UUID{}, ErrUnexpectedError - } + enabled := true + metricsConfig := &extconf.MetricsConfig{ + BaseConfig: extconf.BaseConfig{Enable: &enabled}, + Prometheus: &extconf.PrometheusConfig{Path: "/metrics"}, + } + conf.Extensions = &extconf.ExtensionConfig{ + Metrics: metricsConfig, + } - return guuid.DefaultGenerator.NewV4() -} + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() -type errReader int + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() -func (errReader) Read(p []byte) (int, error) { - return 0, fmt.Errorf("test error") //nolint:goerr113 + // without credentials + resp, err := resty.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // with wrong credentials + resp, err = resty.R().SetBasicAuth("atacker", "wrongpassword").Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // authenticated users + resp, err = resty.R().SetBasicAuth(username, passphrase).Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = resty.R().SetBasicAuth(metricsuser, metricspass).Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) } diff --git a/pkg/api/authz.go b/pkg/api/authz.go index 3228e8b038..42c972478b 100644 --- a/pkg/api/authz.go +++ b/pkg/api/authz.go @@ -191,14 +191,10 @@ func (ac *AccessController) getAuthnMiddlewareContext(authnType string, request func (ac *AccessController) isPermitted(userGroups []string, username, action string, policyGroup config.PolicyGroup, ) bool { - var result bool - // check repo/system based policies for _, p := range policyGroup.Policies { if common.Contains(p.Users, username) && common.Contains(p.Actions, action) { - result = true - - return result + return true } } @@ -207,9 +203,7 @@ func (ac *AccessController) isPermitted(userGroups []string, username, action st if common.Contains(p.Actions, action) { for _, group := range p.Groups { if common.Contains(userGroups, group) { - result = true - - return result + return true } } } @@ -217,20 +211,16 @@ func (ac *AccessController) isPermitted(userGroups []string, username, action st } // check defaultPolicy - if !result { - if common.Contains(policyGroup.DefaultPolicy, action) && username != "" { - result = true - } + if common.Contains(policyGroup.DefaultPolicy, action) && username != "" { + return true } // check anonymousPolicy - if !result { - if common.Contains(policyGroup.AnonymousPolicy, action) && username == "" { - result = true - } + if common.Contains(policyGroup.AnonymousPolicy, action) && username == "" { + return true } - return result + return false } func BaseAuthzHandler(ctlr *Controller) mux.MiddlewareFunc { @@ -343,3 +333,40 @@ func DistSpecAuthzHandler(ctlr *Controller) mux.MiddlewareFunc { }) } } + +func MetricsAuthzHandler(ctlr *Controller) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if ctlr.Config.HTTP.AccessControl == nil { + // allow access to authenticated user as anonymous policy does not exist + next.ServeHTTP(response, request) + + return + } + if len(ctlr.Config.HTTP.AccessControl.Metrics.Users) == 0 { + log := ctlr.Log + log.Warn().Msg("auth is enabled but no metrics users in accessControl: /metrics is unaccesible") + common.AuthzFail(response, request, "", ctlr.Config.HTTP.Realm, ctlr.Config.HTTP.Auth.FailDelay) + + return + } + + // get access control context made in authn.go + userAc, err := reqCtx.UserAcFromContext(request.Context()) + if err != nil { // should never happen + common.AuthzFail(response, request, "", ctlr.Config.HTTP.Realm, ctlr.Config.HTTP.Auth.FailDelay) + + return + } + + username := userAc.GetUsername() + if !common.Contains(ctlr.Config.HTTP.AccessControl.Metrics.Users, username) { + common.AuthzFail(response, request, username, ctlr.Config.HTTP.Realm, ctlr.Config.HTTP.Auth.FailDelay) + + return + } + + next.ServeHTTP(response, request) //nolint:contextcheck + }) + } +} diff --git a/pkg/api/authz_test.go b/pkg/api/authz_test.go new file mode 100644 index 0000000000..54663f945e --- /dev/null +++ b/pkg/api/authz_test.go @@ -0,0 +1,1594 @@ +//go:build sync && scrub && metrics && search && lint && userprefs && mgmt && imagetrust && ui +// +build sync,scrub,metrics,search,lint,userprefs,mgmt,imagetrust,ui + +package api_test + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path" + "testing" + + godigest "github.com/opencontainers/go-digest" + ispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/project-zot/mockoidc" + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/resty.v1" + + "zotregistry.io/zot/pkg/api" + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/api/constants" + apiErr "zotregistry.io/zot/pkg/api/errors" + extconf "zotregistry.io/zot/pkg/extensions/config" + storageConstants "zotregistry.io/zot/pkg/storage/constants" + authutils "zotregistry.io/zot/pkg/test/auth" + test "zotregistry.io/zot/pkg/test/common" + . "zotregistry.io/zot/pkg/test/image-utils" + ociutils "zotregistry.io/zot/pkg/test/oci-utils" +) + +const ( + AuthorizationNamespace = "authz/image" + metricsuser = "metrics" + metricspass = "metrics" +) + +func TestAuthorization(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + htpasswdPath := test.MakeHtpasswdFile() + defer os.Remove(htpasswdPath) + + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } + + Convey("with openid", func() { + mockOIDCServer, err := authutils.MockOIDCRun() + if err != nil { + panic(err) + } + + defer func() { + err := mockOIDCServer.Shutdown() + if err != nil { + panic(err) + } + }() + + mockOIDCConfig := mockOIDCServer.Config() + conf.HTTP.Auth = &config.AuthConfig{ + OpenID: &config.OpenIDConfig{ + Providers: map[string]config.OpenIDProviderConfig{ + "oidc": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"openid", "email"}, + }, + }, + }, + } + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + err = WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", + ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) + So(err, ShouldBeNil) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + client := resty.New() + + client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + mockOIDCServer.QueueUser(&mockoidc.MockUser{ + Email: "test", + Subject: "1234567890", + }) + + // first login user + resp, err := client.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + client.SetCookies(resp.Cookies()) + client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + + RunAuthorizationTests(t, client, baseURL, conf) + }) + + Convey("with basic auth", func() { + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + err := WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", + ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) + So(err, ShouldBeNil) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + client := resty.New() + client.SetBasicAuth(username, passphrase) + + RunAuthorizationTests(t, client, baseURL, conf) + }) + }) +} + +func RunAuthorizationWithMultiplePoliciesTests(t *testing.T, userClient *resty.Client, bobClient *resty.Client, + baseURL string, conf *config.Config, +) { + t.Helper() + + blob := []byte("hello, blob!") + digest := godigest.FromBytes(blob).String() + + // unauthenticated clients should not have access to /v2/, no policy is applied since none exists + resp, err := resty.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 401) + + repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] + repoPolicy.AnonymousPolicy = append(repoPolicy.AnonymousPolicy, "read") + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + + // should have access to /v2/, anonymous policy is applied, "read" allowed + resp, err = resty.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // with empty username:password + resp, err = resty.R().SetHeader("Authorization", "Basic Og==").Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // add "test" user to global policy with create permission + repoPolicy.Policies[0].Users = append(repoPolicy.Policies[0].Users, "test") + repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create") + + // now it should get 202, user has the permission set on "create" + resp, err = userClient.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := resp.Header().Get("Location") + + // uploading blob should get 201 + resp, err = userClient.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // head blob should get 403 without read perm + resp, err = userClient.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // get tags without read access should get 403 + resp, err = userClient.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + repoPolicy.DefaultPolicy = append(repoPolicy.DefaultPolicy, "read") + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + + // with read permission should get 200, because default policy allows reading now + resp, err = userClient.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get tags with default read access should be ok, since the user is now "bob" and default policy is applied + resp, err = bobClient.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get tags with anonymous read access should be ok + resp, err = resty.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // without create permission should get 403, since "bob" can only read(default policy applied) + resp, err = bobClient.R(). + Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add read permission to user "bob" + conf.HTTP.AccessControl.AdminPolicy.Users = append(conf.HTTP.AccessControl.AdminPolicy.Users, "bob") + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "create") + + // added create permission to user "bob", should be allowed now + resp, err = bobClient.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // make sure anonymous is correctly handled when using acCtx (requestcontext package) + catalog := struct { + Repositories []string `json:"repositories"` + }{} + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(catalog.Repositories, ShouldContain, AuthorizationNamespace) + + resp, err = bobClient.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(catalog.Repositories, ShouldContain, AuthorizationNamespace) + + resp, err = userClient.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(catalog.Repositories, ShouldContain, AuthorizationNamespace) + + // no policy + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = config.PolicyGroup{} + + // no policies, so no anonymous allowed + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // bob is admin so he can read + resp, err = bobClient.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(catalog.Repositories, ShouldContain, AuthorizationNamespace) + + // test user has no permissions + resp, err = userClient.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(len(catalog.Repositories), ShouldEqual, 0) +} + +func RunAuthorizationTests(t *testing.T, client *resty.Client, baseURL string, conf *config.Config) { + t.Helper() + + Convey("run authorization tests", func() { + blob := []byte("hello, blob!") + digest := godigest.FromBytes(blob).String() + + // unauthenticated clients should not have access to /v2/ + resp, err := resty.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // everybody should have access to /v2/ + resp, err = client.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // everybody should have access to /v2/_catalog + resp, err = client.R().Get(baseURL + constants.RoutePrefix + constants.ExtCatalogPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var apiErr apiErr.Error + err = json.Unmarshal(resp.Body(), &apiErr) + So(err, ShouldBeNil) + + // should get 403 without create + resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // first let's use global based policies + // add test user to global policy with create perm + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users, "test") //nolint:lll // gofumpt conflicts with lll + + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + + // now it should get 202 + resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := resp.Header().Get("Location") + + // uploading blob should get 201 + resp, err = client.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // head blob should get 403 without read perm + resp, err = client.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // get tags without read access should get 403 + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // get tags with read access should get 200 + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "read") //nolint:lll // gofumpt conflicts with lll + + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // head blob should get 200 now + resp, err = client.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get blob should get 200 now + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // delete blob should get 403 without delete perm + resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add delete perm on repo + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "delete") //nolint:lll // gofumpt conflicts with lll + + // delete blob should get 202 + resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + + // now let's use only repository based policies + // add test user to repo's policy with create perm + // longest path matching should match the repo and not **/* + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + } + + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + + // now it should get 202 + resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = resp.Header().Get("Location") + + // uploading blob should get 201 + resp, err = client.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // head blob should get 403 without read perm + resp, err = client.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // get tags without read access should get 403 + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // get tags with read access should get 200 + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "read") //nolint:lll // gofumpt conflicts with lll + + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // head blob should get 200 now + resp, err = client.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // get blob should get 200 now + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // delete blob should get 403 without delete perm + resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add delete perm on repo + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "delete") //nolint:lll // gofumpt conflicts with lll + + // delete blob should get 202 + resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + + // remove permissions on **/* so it will not interfere with zot-test namespace + repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] + repoPolicy.Policies = []config.Policy{} + repoPolicy.DefaultPolicy = []string{} + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + + // get manifest should get 403, we don't have perm at all on this repo + resp, err = client.R().Get(baseURL + "/v2/zot-test/manifests/0.0.1") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add read perm on repo + conf.HTTP.AccessControl.Repositories["zot-test"] = config.PolicyGroup{Policies: []config.Policy{ + { + Users: []string{"test"}, + Actions: []string{"read"}, + }, + }, DefaultPolicy: []string{}} + + /* we have 4 images(authz/image, golang, zot-test, zot-cve-test) in storage, + but because at this point we only have read access + in authz/image and zot-test, we should get only that when listing repositories*/ + resp, err = client.R().Get(baseURL + constants.RoutePrefix + constants.ExtCatalogPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + err = json.Unmarshal(resp.Body(), &apiErr) + So(err, ShouldBeNil) + + catalog := struct { + Repositories []string `json:"repositories"` + }{} + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(len(catalog.Repositories), ShouldEqual, 2) + So(catalog.Repositories, ShouldContain, "zot-test") + So(catalog.Repositories, ShouldContain, AuthorizationNamespace) + + // get manifest should get 200 now + resp, err = client.R().Get(baseURL + "/v2/zot-test/manifests/0.0.1") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + manifestBlob := resp.Body() + var manifest ispec.Manifest + + err = json.Unmarshal(manifestBlob, &manifest) + So(err, ShouldBeNil) + + // put manifest should get 403 without create perm + resp, err = client.R(). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add create perm on repo + conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + + // should get 201 with create perm + resp, err = client.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // create update config and post it. + cblob, cdigest := GetRandomImageConfig() + + resp, err = client.R(). + Post(baseURL + "/v2/zot-test/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + + // uploading blob should get 201 + resp, err = client.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // create updated layer and post it + updateBlob := []byte("Hello, blob update!") + + resp, err = client.R().Post(baseURL + "/v2/zot-test/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + + // uploading blob should get 201 + resp, err = client.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(updateBlob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", string(godigest.FromBytes(updateBlob))). + SetBody(updateBlob). + Put(loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + updatedManifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: godigest.FromBytes(updateBlob), + Size: int64(len(updateBlob)), + }, + }, + } + updatedManifest.SchemaVersion = 2 + updatedManifestBlob, err := json.Marshal(updatedManifest) + So(err, ShouldBeNil) + + // update manifest should get 403 without update perm + resp, err = client.R(). + SetBody(updatedManifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // get the manifest and check if it's the old one + resp, err = client.R().Get(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldResemble, manifestBlob) + + // add update perm on repo + conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions, "update") //nolint:lll // gofumpt conflicts with lll + + // update manifest should get 201 with update perm + resp, err = client.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(updatedManifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // get the manifest and check if it's the new updated one + resp, err = client.R().Get(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldResemble, updatedManifestBlob) + + // now use default repo policy + conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{} + repoPolicy = conf.HTTP.AccessControl.Repositories["zot-test"] + repoPolicy.DefaultPolicy = []string{"update"} + conf.HTTP.AccessControl.Repositories["zot-test"] = repoPolicy + + // update manifest should get 201 with update perm on repo's default policy + resp, err = client.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // with default read on repo should still get 200 + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = []string{} + repoPolicy = conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] + repoPolicy.DefaultPolicy = []string{"read"} + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // upload blob without user create but with default create should get 200 + repoPolicy.DefaultPolicy = append(repoPolicy.DefaultPolicy, "create") + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + + resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + + // remove per repo policy + repoPolicy = conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] + repoPolicy.Policies = []config.Policy{} + repoPolicy.DefaultPolicy = []string{} + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + + repoPolicy = conf.HTTP.AccessControl.Repositories["zot-test"] + repoPolicy.Policies = []config.Policy{} + repoPolicy.DefaultPolicy = []string{} + conf.HTTP.AccessControl.Repositories["zot-test"] = repoPolicy + + resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // whithout any perm should get 403 + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add read perm + conf.HTTP.AccessControl.AdminPolicy.Users = append(conf.HTTP.AccessControl.AdminPolicy.Users, "test") + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "read") + + // with read perm should get 200 + resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // without create perm should 403 + resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add create perm + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "create") + + // with create perm should get 202 + resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = resp.Header().Get("Location") + + // uploading blob should get 201 + resp, err = client.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // without delete perm should 403 + resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add delete perm + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "delete") + + // with delete perm should get http.StatusAccepted + resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + + // without update perm should 403 + resp, err = client.R(). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // add update perm + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "update") + + // update manifest should get 201 with update perm + resp, err = client.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + conf.HTTP.AccessControl = &config.AccessControlConfig{} + + resp, err = client.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/zot-test/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + }) +} + +func TestAuthorizationWithOnlyAnonymousPolicy(t *testing.T) { + Convey("Make a new controller", t, func() { + const TestRepo = "my-repos/repo" + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{} + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + TestRepo: config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + }, + } + + dir := t.TempDir() + ctlr := makeController(conf, dir) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + blob := []byte("hello, blob!") + digest := godigest.FromBytes(blob).String() + + resp, err := resty.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var e apiErr.Error + err = json.Unmarshal(resp.Body(), &e) + So(err, ShouldBeNil) + + // should get 401 without create + resp, err = resty.R().Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + if entry, ok := conf.HTTP.AccessControl.Repositories[TestRepo]; ok { + entry.AnonymousPolicy = []string{"create", "read"} + conf.HTTP.AccessControl.Repositories[TestRepo] = entry + } + + // now it should get 202 + resp, err = resty.R().Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := resp.Header().Get("Location") + + // uploading blob should get 201 + resp, err = resty.R().SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + cblob, cdigest := GetRandomImageConfig() + + resp, err = resty.R().Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + + // uploading blob should get 201 + resp, err = resty.R().SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: godigest.FromBytes(blob), + Size: int64(len(blob)), + }, + }, + } + manifest.SchemaVersion = 2 + manifestBlob, err := json.Marshal(manifest) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + updateBlob := []byte("Hello, blob update!") + + resp, err = resty.R(). + Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + // uploading blob should get 201 + resp, err = resty.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(updateBlob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", string(godigest.FromBytes(updateBlob))). + SetBody(updateBlob). + Put(loc) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + updatedManifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: godigest.FromBytes(updateBlob), + Size: int64(len(updateBlob)), + }, + }, + } + updatedManifest.SchemaVersion = 2 + updatedManifestBlob, err := json.Marshal(updatedManifest) + So(err, ShouldBeNil) + + // update manifest should get 401 without update perm + resp, err = resty.R().SetBody(updatedManifestBlob). + Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // get the manifest and check if it's the old one + resp, err = resty.R(). + Get(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldResemble, manifestBlob) + + // add update perm on repo + if entry, ok := conf.HTTP.AccessControl.Repositories[TestRepo]; ok { + entry.AnonymousPolicy = []string{"create", "read", "update"} + conf.HTTP.AccessControl.Repositories[TestRepo] = entry + } + + // update manifest should get 201 with update perm + resp, err = resty.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(updatedManifestBlob). + Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // get the manifest and check if it's the new updated one + resp, err = resty.R(). + Get(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldResemble, updatedManifestBlob) + + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // make sure anonymous is correctly handled when using acCtx (requestcontext package) + catalog := struct { + Repositories []string `json:"repositories"` + }{} + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(len(catalog.Repositories), ShouldEqual, 1) + So(catalog.Repositories, ShouldContain, TestRepo) + + err = os.Mkdir(path.Join(dir, "zot-test"), storageConstants.DefaultDirPerms) + So(err, ShouldBeNil) + + err = WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "tag", ctlr.StoreController) + So(err, ShouldBeNil) + + // should not have read rights on zot-test + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(len(catalog.Repositories), ShouldEqual, 1) + So(catalog.Repositories, ShouldContain, TestRepo) + + // add rights + conf.HTTP.AccessControl.Repositories["zot-test"] = config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + } + + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(len(catalog.Repositories), ShouldEqual, 2) + So(catalog.Repositories, ShouldContain, TestRepo) + So(catalog.Repositories, ShouldContain, "zot-test") + }) +} + +func TestAuthorizationWithAnonymousPolicyBasicAuthAndSessionHeader(t *testing.T) { + Convey("Make a new controller", t, func() { + const TestRepo = "my-repos/repo" + const AllRepos = "**" + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + badpassphrase := "bad" + htpasswdContent := fmt.Sprintf("%s:$2y$05$hlbSXDp6hzDLu6VwACS39ORvVRpr3OMR4RlJ31jtlaOEGnPjKZI1m\n", + htpasswdUsername) + + htpasswdPath := test.MakeHtpasswdFileFromString(htpasswdContent) + defer os.Remove(htpasswdPath) + + img := CreateRandomImage() + tagAnonymous := "1.0-anon" + tagAuth := "1.0-auth" + tagUnauth := "1.0-unauth" + + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{htpasswdUsername}, + Actions: []string{"read"}, + }, + }, + AnonymousPolicy: []string{"read"}, + }, + }, + } + + dir := t.TempDir() + ctlr := makeController(conf, dir) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // /v2 access + // Can access /v2 without credentials + resp, err := resty.R().Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Can access /v2 without credentials and with X-Zot-Api-Client=zot-ui + resp, err = resty.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Can access /v2 with correct credentials + resp, err = resty.R(). + SetBasicAuth(htpasswdUsername, passphrase). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Fail to access /v2 with incorrect credentials + resp, err = resty.R(). + SetBasicAuth(htpasswdUsername, badpassphrase). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // Catalog access + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var apiError apiErr.Error + err = json.Unmarshal(resp.Body(), &apiError) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + apiError = apiErr.Error{} + err = json.Unmarshal(resp.Body(), &apiError) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetBasicAuth(htpasswdUsername, passphrase). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + apiError = apiErr.Error{} + err = json.Unmarshal(resp.Body(), &apiError) + So(err, ShouldBeNil) + + resp, err = resty.R(). + SetBasicAuth(htpasswdUsername, badpassphrase). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + apiError = apiErr.Error{} + err = json.Unmarshal(resp.Body(), &apiError) + So(err, ShouldBeNil) + + // upload capability + // should get 403 without create + err = UploadImage(img, baseURL, TestRepo, tagAnonymous) + So(err, ShouldNotBeNil) + + err = UploadImageWithBasicAuth(img, baseURL, + TestRepo, tagAuth, htpasswdUsername, passphrase) + So(err, ShouldNotBeNil) + + err = UploadImageWithBasicAuth(img, baseURL, + TestRepo, tagUnauth, htpasswdUsername, badpassphrase) + So(err, ShouldNotBeNil) + + if entry, ok := conf.HTTP.AccessControl.Repositories[AllRepos]; ok { + entry.AnonymousPolicy = []string{"create", "read"} + entry.Policies[0] = config.Policy{ + Users: []string{htpasswdUsername}, + Actions: []string{"create", "read"}, + } + conf.HTTP.AccessControl.Repositories[AllRepos] = entry + } + + // now it should succeed for valid users + err = UploadImage(img, baseURL, TestRepo, tagAnonymous) + So(err, ShouldBeNil) + + err = UploadImageWithBasicAuth(img, baseURL, + TestRepo, tagAuth, htpasswdUsername, passphrase) + So(err, ShouldBeNil) + + err = UploadImageWithBasicAuth(img, baseURL, + TestRepo, tagUnauth, htpasswdUsername, badpassphrase) + So(err, ShouldNotBeNil) + + // read capability + catalog := struct { + Repositories []string `json:"repositories"` + }{} + + resp, err = resty.R().Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(len(catalog.Repositories), ShouldEqual, 1) + So(catalog.Repositories, ShouldContain, TestRepo) + + catalog = struct { + Repositories []string `json:"repositories"` + }{} + + resp, err = resty.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(len(catalog.Repositories), ShouldEqual, 1) + So(catalog.Repositories, ShouldContain, TestRepo) + + catalog = struct { + Repositories []string `json:"repositories"` + }{} + + resp, err = resty.R(). + SetBasicAuth(htpasswdUsername, passphrase). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + err = json.Unmarshal(resp.Body(), &catalog) + So(err, ShouldBeNil) + So(len(catalog.Repositories), ShouldEqual, 1) + So(catalog.Repositories, ShouldContain, TestRepo) + + resp, err = resty.R(). + SetBasicAuth(htpasswdUsername, badpassphrase). + Get(baseURL + "/v2/_catalog") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) +} + +func TestAuthorizationWithMultiplePolicies(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + // have two users: "test" user for user Policy, and "bob" for default policy + htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase) + + "\n" + getCredString("bob", passphrase)) + defer os.Remove(htpasswdPath) + + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + // config with all policy types, to test that the correct one is applied in each case + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + AnonymousPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } + + Convey("with openid", func() { + dir := t.TempDir() + + mockOIDCServer, err := authutils.MockOIDCRun() + if err != nil { + panic(err) + } + + defer func() { + err := mockOIDCServer.Shutdown() + if err != nil { + panic(err) + } + }() + + mockOIDCConfig := mockOIDCServer.Config() + conf.HTTP.Auth = &config.AuthConfig{ + OpenID: &config.OpenIDConfig{ + Providers: map[string]config.OpenIDProviderConfig{ + "oidc": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"openid", "email"}, + }, + }, + }, + } + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = dir + + err = WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", + ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) + So(err, ShouldBeNil) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + testUserClient := resty.New() + + testUserClient.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + mockOIDCServer.QueueUser(&mockoidc.MockUser{ + Email: "test", + Subject: "1234567890", + }) + + // first login user + resp, err := testUserClient.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + testUserClient.SetCookies(resp.Cookies()) + testUserClient.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + + bobUserClient := resty.New() + + bobUserClient.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + + mockOIDCServer.QueueUser(&mockoidc.MockUser{ + Email: "bob", + Subject: "1234567890", + }) + + // first login user + resp, err = bobUserClient.R(). + SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). + SetQueryParam("provider", "oidc"). + Get(baseURL + constants.LoginPath) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + bobUserClient.SetCookies(resp.Cookies()) + bobUserClient.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + + RunAuthorizationWithMultiplePoliciesTests(t, testUserClient, bobUserClient, baseURL, conf) + }) + + Convey("with basic auth", func() { + dir := t.TempDir() + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = dir + + err := WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", + ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) + So(err, ShouldBeNil) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + testUserClient := resty.New() + testUserClient.SetBasicAuth(username, passphrase) + + bobUserClient := resty.New() + bobUserClient.SetBasicAuth("bob", passphrase) + + RunAuthorizationWithMultiplePoliciesTests(t, testUserClient, bobUserClient, baseURL, conf) + }) + }) +} + +func TestMetricsAuthorization(t *testing.T) { + Convey("Make a new controller with auth & metrics enabled", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + credTests := fmt.Sprintf("%s\n%s\n", getCredString(username, passphrase), getCredString(metricsuser, metricspass)) + htpasswdPath := test.MakeHtpasswdFileFromString(credTests) + defer os.Remove(htpasswdPath) + + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + + enabled := true + metricsConfig := &extconf.MetricsConfig{ + BaseConfig: extconf.BaseConfig{Enable: &enabled}, + Prometheus: &extconf.PrometheusConfig{Path: "/metrics"}, + } + conf.Extensions = &extconf.ExtensionConfig{ + Metrics: metricsConfig, + } + + Convey("with basic auth: no metrics users in accessControl", func() { + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Metrics: config.Metrics{ + Users: []string{}, + }, + } + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // authenticated but not authorized user should not have access to/metrics + client := resty.New() + client.SetBasicAuth(username, passphrase) + resp, err := client.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // authenticated but not authorized user should not have access to/metrics + client.SetBasicAuth(metricsuser, metricspass) + resp, err = client.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + Convey("with basic auth: metrics users in accessControl", func() { + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Metrics: config.Metrics{ + Users: []string{metricsuser}, + }, + } + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // authenticated but not authorized user should not have access to/metrics + client := resty.New() + client.SetBasicAuth(username, passphrase) + resp, err := client.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // authenticated & authorized user should have access to/metrics + client.SetBasicAuth(metricsuser, metricspass) + resp, err = client.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) + Convey("with basic auth: with anonymousPolicy in accessControl", func() { + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Metrics: config.Metrics{ + Users: []string{metricsuser}, + }, + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + AnonymousPolicy: []string{"read"}, + DefaultPolicy: []string{}, + }, + }, + } + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // unauthenticated clients should not have access to /metrics + resp, err := resty.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // unauthenticated clients should not have access to /metrics + resp, err = resty.R().SetBasicAuth("hacker", "trywithwrongpass").Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // authenticated but not authorized user should not have access to/metrics + client := resty.New() + client.SetBasicAuth(username, passphrase) + resp, err = client.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // authenticated & authorized user should have access to/metrics + client.SetBasicAuth(metricsuser, metricspass) + resp, err = client.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) + Convey("with basic auth: with adminPolicy in accessControl", func() { + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Metrics: config.Metrics{ + Users: []string{metricsuser}, + }, + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{"test"}, + Groups: []string{"admins"}, + Actions: []string{"read", "create", "update", "delete"}, + }, + } + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // unauthenticated clients should not have access to /metrics + resp, err := resty.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // unauthenticated clients should not have access to /metrics + resp, err = resty.R().SetBasicAuth("hacker", "trywithwrongpass").Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // authenticated admin user (but not authorized) should not have access to/metrics + client := resty.New() + client.SetBasicAuth(username, passphrase) + resp, err = client.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // authenticated & authorized user should have access to/metrics + client.SetBasicAuth(metricsuser, metricspass) + resp, err = client.R().Get(baseURL + "/metrics") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) + }) +} diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 8ab156e328..c6ed53da26 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -131,6 +131,7 @@ type AccessControlConfig struct { Repositories Repositories `json:"repositories" mapstructure:"repositories"` AdminPolicy Policy Groups Groups + Metrics Metrics } func (config *AccessControlConfig) AnonymousPolicyExists() bool { @@ -168,6 +169,10 @@ type Policy struct { Groups []string } +type Metrics struct { + Users []string +} + type Config struct { DistSpecVersion string `json:"distSpecVersion" mapstructure:"distSpecVersion"` GoVersion string diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 05b53f125a..0566f1685a 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -7,8 +7,6 @@ import ( "bufio" "bytes" "context" - "crypto/tls" - "crypto/x509" "encoding/json" goerrors "errors" "fmt" @@ -19,7 +17,6 @@ import ( "net/url" "os" "path" - "path/filepath" "sort" "strconv" "strings" @@ -28,8 +25,6 @@ import ( "github.com/google/go-github/v52/github" "github.com/gorilla/mux" - "github.com/gorilla/securecookie" - "github.com/gorilla/sessions" "github.com/migueleliasweb/go-github-mock/src/mock" vldap "github.com/nmcclain/ldap" notreg "github.com/notaryproject/notation-go/registry" @@ -37,7 +32,6 @@ import ( godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" artifactspec "github.com/oras-project/artifacts-spec/specs-go/v1" - "github.com/project-zot/mockoidc" "github.com/sigstore/cosign/v2/cmd/cosign/cli/generate" "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" @@ -66,26 +60,24 @@ import ( "zotregistry.io/zot/pkg/test/deprecated" . "zotregistry.io/zot/pkg/test/image-utils" "zotregistry.io/zot/pkg/test/inject" - "zotregistry.io/zot/pkg/test/mocks" ociutils "zotregistry.io/zot/pkg/test/oci-utils" "zotregistry.io/zot/pkg/test/signature" tskip "zotregistry.io/zot/pkg/test/skip" ) const ( - username = "test" - htpasswdUsername = "htpasswduser" - passphrase = "test" - group = "test" - repo = "test" - ServerCert = "../../test/data/server.cert" - ServerKey = "../../test/data/server.key" - CACert = "../../test/data/ca.crt" - AuthorizedNamespace = "everyone/isallowed" - UnauthorizedNamespace = "fortknox/notallowed" - ALICE = "alice" - AuthorizationNamespace = "authz/image" - AuthorizationAllRepos = "**" + username = "test" + htpasswdUsername = "htpasswduser" + passphrase = "test" + group = "test" + repo = "test" + ServerCert = "../../test/data/server.cert" + ServerKey = "../../test/data/server.key" + CACert = "../../test/data/ca.crt" + AuthorizedNamespace = "everyone/isallowed" + UnauthorizedNamespace = "fortknox/notallowed" + ALICE = "alice" + AuthorizationAllRepos = "**" ) func getCredString(username, password string) string { @@ -517,242 +509,6 @@ func TestObjectStorageControllerSubPaths(t *testing.T) { }) } -func TestHtpasswdSingleCred(t *testing.T) { - Convey("Single cred", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - singleCredtests := []string{} - user := ALICE - password := ALICE - singleCredtests = append(singleCredtests, getCredString(user, password)) - singleCredtests = append(singleCredtests, getCredString(user, password)+"\n") - - for _, testString := range singleCredtests { - func() { - conf := config.New() - conf.HTTP.Port = port - - htpasswdPath := test.MakeHtpasswdFileFromString(testString) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - - conf.HTTP.AllowOrigin = conf.HTTP.Address - - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - // with creds, should get expected status code - resp, _ := resty.R().SetBasicAuth(user, password).Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - header := []string{"Authorization,content-type," + constants.SessionClientHeaderName} - - resp, _ = resty.R().SetBasicAuth(user, password).Options(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) - So(len(resp.Header()), ShouldEqual, 5) - So(resp.Header()["Access-Control-Allow-Headers"], ShouldResemble, header) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - - // with invalid creds, it should fail - resp, _ = resty.R().SetBasicAuth("chuck", "chuck").Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }() - } - }) -} - -func TestAllowMethodsHeader(t *testing.T) { - Convey("Options request", t, func() { - dir := t.TempDir() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - conf.Storage.RootDirectory = dir - conf.HTTP.AllowOrigin = "someOrigin" - - simpleUser := "simpleUser" - simpleUserPassword := "simpleUserPass" - credTests := fmt.Sprintf("%s\n\n", getCredString(simpleUser, simpleUserPassword)) - - htpasswdPath := test.MakeHtpasswdFileFromString(credTests) - defer os.Remove(htpasswdPath) - - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - "**": config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{simpleUser}, - Actions: []string{"read", "create"}, - }, - }, - }, - }, - } - - defaultVal := true - conf.Extensions = &extconf.ExtensionConfig{ - Search: &extconf.SearchConfig{BaseConfig: extconf.BaseConfig{Enable: &defaultVal}}, - } - - ctlr := api.NewController(conf) - - ctlrManager := test.NewControllerManager(ctlr) - ctlrManager.StartAndWait(port) - defer ctlrManager.StopServer() - - simpleUserClient := resty.R().SetBasicAuth(simpleUser, simpleUserPassword) - - digest := godigest.FromString("digest") - - // /v2 - resp, err := simpleUserClient.Options(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - - // /v2/{name}/tags/list - resp, err = simpleUserClient.Options(baseURL + "/v2/reponame/tags/list") - So(err, ShouldBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - - // /v2/{name}/manifests/{reference} - resp, err = simpleUserClient.Options(baseURL + "/v2/reponame/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "HEAD,GET,OPTIONS") - - // /v2/{name}/referrers/{digest} - resp, err = simpleUserClient.Options(baseURL + "/v2/reponame/referrers/" + digest.String()) - So(err, ShouldBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - - // /v2/_catalog - resp, err = simpleUserClient.Options(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - - // /v2/_oci/ext/discover - resp, err = simpleUserClient.Options(baseURL + "/v2/_oci/ext/discover") - So(err, ShouldBeNil) - So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS") - }) -} - -func TestHtpasswdTwoCreds(t *testing.T) { - Convey("Two creds", t, func() { - twoCredTests := []string{} - user1 := "alicia" - password1 := "aliciapassword" - user2 := "bob" - password2 := "robert" - twoCredTests = append(twoCredTests, getCredString(user1, password1)+"\n"+ - getCredString(user2, password2)) - - twoCredTests = append(twoCredTests, getCredString(user1, password1)+"\n"+ - getCredString(user2, password2)+"\n") - - twoCredTests = append(twoCredTests, getCredString(user1, password1)+"\n\n"+ - getCredString(user2, password2)+"\n\n") - - for _, testString := range twoCredTests { - func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - htpasswdPath := test.MakeHtpasswdFileFromString(testString) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - ctlr := makeController(conf, t.TempDir()) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - // with creds, should get expected status code - resp, _ := resty.R().SetBasicAuth(user1, password1).Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, _ = resty.R().SetBasicAuth(user2, password2).Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // with invalid creds, it should fail - resp, _ = resty.R().SetBasicAuth("chuck", "chuck").Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }() - } - }) -} - -func TestHtpasswdFiveCreds(t *testing.T) { - Convey("Five creds", t, func() { - tests := map[string]string{ - "michael": "scott", - "jim": "halpert", - "dwight": "shrute", - "pam": "bessley", - "creed": "bratton", - } - credString := strings.Builder{} - for key, val := range tests { - credString.WriteString(getCredString(key, val) + "\n") - } - - func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - htpasswdPath := test.MakeHtpasswdFileFromString(credString.String()) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - // with creds, should get expected status code - for key, val := range tests { - resp, _ := resty.R().SetBasicAuth(key, val).Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - } - - // with invalid creds, it should fail - resp, _ := resty.R().SetBasicAuth("chuck", "chuck").Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }() - }) -} - func TestRatelimit(t *testing.T) { Convey("Make a new controller", t, func() { port := test.GetFreePort() @@ -1320,47 +1076,124 @@ func TestMultipleInstance(t *testing.T) { }) } -func TestTLSWithBasicAuth(t *testing.T) { +const ( + LDAPAddress = "127.0.0.1" + LDAPBaseDN = "ou=test" + LDAPBindDN = "cn=reader," + LDAPBaseDN + LDAPBindPassword = "bindPassword" +) + +type testLDAPServer struct { + server *vldap.Server + quitCh chan bool +} + +func newTestLDAPServer() *testLDAPServer { + ldaps := &testLDAPServer{} + quitCh := make(chan bool) + server := vldap.NewServer() + server.QuitChannel(quitCh) + server.BindFunc("", ldaps) + server.SearchFunc("", ldaps) + ldaps.server = server + ldaps.quitCh = quitCh + + return ldaps +} + +func (l *testLDAPServer) Start(port int) { + addr := fmt.Sprintf("%s:%d", LDAPAddress, port) + + go func() { + if err := l.server.ListenAndServe(addr); err != nil { + panic(err) + } + }() + + for { + _, err := net.Dial("tcp", addr) + if err == nil { + break + } + + time.Sleep(10 * time.Millisecond) + } +} + +func (l *testLDAPServer) Stop() { + l.quitCh <- true +} + +func (l *testLDAPServer) Bind(bindDN, bindSimplePw string, conn net.Conn) (vldap.LDAPResultCode, error) { + if bindDN == "" || bindSimplePw == "" { + return vldap.LDAPResultInappropriateAuthentication, errors.ErrRequireCred + } + + if (bindDN == LDAPBindDN && bindSimplePw == LDAPBindPassword) || + (bindDN == fmt.Sprintf("cn=%s,%s", username, LDAPBaseDN) && bindSimplePw == passphrase) { + return vldap.LDAPResultSuccess, nil + } + + return vldap.LDAPResultInvalidCredentials, errors.ErrInvalidCred +} + +func (l *testLDAPServer) Search(boundDN string, req vldap.SearchRequest, + conn net.Conn, +) (vldap.ServerSearchResult, error) { + check := fmt.Sprintf("(uid=%s)", username) + if check == req.Filter { + return vldap.ServerSearchResult{ + Entries: []*vldap.Entry{ + { + DN: fmt.Sprintf("cn=%s,%s", username, LDAPBaseDN), + Attributes: []*vldap.EntryAttribute{ + { + Name: "memberOf", + Values: []string{group}, + }, + }, + }, + }, + ResultCode: vldap.LDAPResultSuccess, + }, nil + } + + return vldap.ServerSearchResult{}, nil +} + +func TestBasicAuthWithLDAP(t *testing.T) { Convey("Make a new controller", t, func() { - caCert, err := os.ReadFile(CACert) + l := newTestLDAPServer() + port := test.GetFreePort() + ldapPort, err := strconv.Atoi(port) So(err, ShouldBeNil) - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) + l.Start(ldapPort) + defer l.Stop() - port := test.GetFreePort() + port = test.GetFreePort() baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - defer func() { resty.SetTLSClientConfig(nil) }() conf := config.New() conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: ServerCert, - Key: ServerKey, - } conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, + LDAP: &config.LDAPConfig{ + Insecure: true, + Address: LDAPAddress, + Port: ldapPort, + BindDN: LDAPBindDN, + BindPassword: LDAPBindPassword, + BaseDN: LDAPBaseDN, + UserAttribute: "uid", }, } - ctlr := makeController(conf, t.TempDir()) cm := test.NewControllerManager(ctlr) cm.StartAndWait(port) defer cm.StopServer() - // accessing insecure HTTP site should fail - resp, err := resty.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - // without creds, should get access error - resp, err = resty.R().Get(secureBaseURL + "/v2/") + resp, err := resty.R().Get(baseURL + "/v2/") So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) @@ -1369,8447 +1202,4523 @@ func TestTLSWithBasicAuth(t *testing.T) { So(err, ShouldBeNil) // with creds, should get expected status code - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(baseURL) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(baseURL + "/v2/") So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // missing password + resp, _ = resty.R().SetBasicAuth(username, "").Get(baseURL + "/v2/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) }) } -func TestTLSWithBasicAuthAllowReadAccess(t *testing.T) { +func TestGroupsPermissionsForLDAP(t *testing.T) { Convey("Make a new controller", t, func() { - caCert, err := os.ReadFile(CACert) + l := newTestLDAPServer() + port := test.GetFreePort() + ldapPort, err := strconv.Atoi(port) So(err, ShouldBeNil) - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) + l.Start(ldapPort) + defer l.Stop() - port := test.GetFreePort() + port = test.GetFreePort() baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) + tempDir := t.TempDir() - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - defer func() { resty.SetTLSClientConfig(nil) }() conf := config.New() conf.HTTP.Port = port conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, + LDAP: &config.LDAPConfig{ + Insecure: true, + Address: LDAPAddress, + Port: ldapPort, + BindDN: LDAPBindDN, + BindPassword: LDAPBindPassword, + BaseDN: LDAPBaseDN, + UserAttribute: "uid", + UserGroupAttribute: "memberOf", }, } - conf.HTTP.TLS = &config.TLSConfig{ - Cert: ServerCert, - Key: ServerKey, - } conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - AnonymousPolicy: []string{"read"}, + Groups: config.Groups{ + group: { + Users: []string{username}, }, }, - } - - ctlr := makeController(conf, t.TempDir()) - + Repositories: config.Repositories{ + repo: config.PolicyGroup{ + Policies: []config.Policy{ + { + Groups: []string{group}, + Actions: []string{"read", "create"}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } + + ctlr := makeController(conf, tempDir) + cm := test.NewControllerManager(ctlr) cm.StartAndWait(port) defer cm.StopServer() - // accessing insecure HTTP site should fail - resp, err := resty.R().Get(baseURL) + img := CreateDefaultImage() + + err = UploadImageWithBasicAuth( + img, baseURL, repo, img.DigestStr(), + username, passphrase) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) +} - // without creds, should still be allowed to access - resp, err = resty.R().Get(secureBaseURL + "/v2/") +func TestLDAPFailures(t *testing.T) { + Convey("Make a LDAP conn", t, func() { + l := newTestLDAPServer() + port := test.GetFreePort() + ldapPort, err := strconv.Atoi(port) So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + l.Start(ldapPort) + defer l.Stop() - // with creds, should get expected status code - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + Convey("Empty config", func() { + lc := &api.LDAPClient{} + err := lc.Connect() + So(err, ShouldNotBeNil) + }) - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + Convey("Basic connectivity config", func() { + lc := &api.LDAPClient{ + Host: LDAPAddress, + Port: ldapPort, + } + err := lc.Connect() + So(err, ShouldNotBeNil) + }) - // without creds, writes should fail - resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + Convey("Basic TLS connectivity config", func() { + lc := &api.LDAPClient{ + Host: LDAPAddress, + Port: ldapPort, + UseSSL: true, + } + err := lc.Connect() + So(err, ShouldNotBeNil) + }) }) } -func TestMutualTLSAuthWithUserPermissions(t *testing.T) { +func TestBearerAuth(t *testing.T) { Convey("Make a new controller", t, func() { - caCert, err := os.ReadFile(CACert) - So(err, ShouldBeNil) - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) + authTestServer := authutils.MakeAuthTestServer(ServerKey, UnauthorizedNamespace) + defer authTestServer.Close() port := test.GetFreePort() baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - defer func() { resty.SetTLSClientConfig(nil) }() conf := config.New() conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: ServerCert, - Key: ServerKey, - CACert: CACert, - } + aurl, err := url.Parse(authTestServer.URL) + So(err, ShouldBeNil) - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"*"}, - Actions: []string{"read"}, - }, - }, - }, + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Cert: ServerCert, + Realm: authTestServer.URL + "/auth/token", + Service: aurl.Host, }, } - ctlr := makeController(conf, t.TempDir()) cm := test.NewControllerManager(ctlr) cm.StartAndWait(port) defer cm.StopServer() - resp, err := resty.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] + blob := []byte("hello, blob!") + digest := godigest.FromBytes(blob).String() - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") + resp, err := resty.R().Get(baseURL + "/v2/") So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - resty.SetCertificates(cert) - defer func() { resty.SetCertificates(tls.Certificate{}) }() - - // with client certs but without creds, should succeed - resp, err = resty.R().Get(secureBaseURL + "/v2/") + authorizationHeader := authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var goodToken authutils.AccessTokenResponse + err = json.Unmarshal(resp.Body(), &goodToken) + So(err, ShouldBeNil) - resp, err = resty.R().Get(secureBaseURL + "/v2/_catalog") + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + Get(baseURL + "/v2/") So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) - // with creds, should get expected status code - resp, _ = resty.R().Get(secureBaseURL) + resp, err = resty.R().SetHeader("Authorization", + fmt.Sprintf("Bearer %s", goodToken.AccessToken)).Options(baseURL + "/v2/") + So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) - // reading a repo should not get 403 - resp, err = resty.R().Get(secureBaseURL + "/v2/repo/tags/list") + resp, err = resty.R().Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - // without creds, writes should fail - resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - // empty default authorization and give user the permission to create - repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create") - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy - resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + err = json.Unmarshal(resp.Body(), &goodToken) So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - }) -} -func TestMutualTLSAuthWithoutCN(t *testing.T) { - Convey("Make a new controller", t, func() { - caCert, err := os.ReadFile("../../test/data/noidentity/ca.crt") + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") So(err, ShouldBeNil) - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - - port := test.GetFreePort() - secureBaseURL := test.GetSecureBaseURL(port) - - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - defer func() { resty.SetTLSClientConfig(nil) }() - conf := config.New() - conf.HTTP.Port = port - - conf.HTTP.TLS = &config.TLSConfig{ - Cert: "../../test/data/noidentity/server.cert", - Key: "../../test/data/noidentity/server.key", - CACert: "../../test/data/noidentity/ca.crt", - } - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{"*"}, - Actions: []string{"read"}, - }, - }, - }, - }, - } - - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := resp.Header().Get("Location") - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair("../../test/data/noidentity/client.cert", "../../test/data/noidentity/client.key") + resp, err = resty.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) So(err, ShouldBeNil) - - resty.SetCertificates(cert) - defer func() { resty.SetCertificates(tls.Certificate{}) }() - - // with client certs but without TLS mutual auth setup should get certificate error - resp, _ := resty.R().Get(secureBaseURL + "/v2/_catalog") + So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) -} -func TestTLSMutualAuth(t *testing.T) { - Convey("Make a new controller", t, func() { - caCert, err := os.ReadFile(CACert) + authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + err = json.Unmarshal(resp.Body(), &goodToken) So(err, ShouldBeNil) - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - defer func() { resty.SetTLSClientConfig(nil) }() - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: ServerCert, - Key: ServerKey, - CACert: CACert, - } - - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - // accessing insecure HTTP site should fail - resp, err := resty.R().Get(baseURL) + resp, err = resty.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // without client certs and creds, should get conn error - _, err = resty.R().Get(secureBaseURL) - So(err, ShouldNotBeNil) - - // with creds but without certs, should get conn error - _, err = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) - So(err, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + Get(baseURL + "/v2/" + AuthorizedNamespace + "/tags/list") So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - resty.SetCertificates(cert) - defer func() { resty.SetCertificates(tls.Certificate{}) }() + authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + err = json.Unmarshal(resp.Body(), &goodToken) + So(err, ShouldBeNil) - // with client certs but without creds, should succeed - resp, err = resty.R().Get(secureBaseURL + "/v2/") + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). + Get(baseURL + "/v2/" + AuthorizedNamespace + "/tags/list") So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) - // with client certs and creds, should get expected status code - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + resp, err = resty.R(). + Post(baseURL + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/") + So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - // with client certs, creds shouldn't matter - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") + authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) + resp, err = resty.R(). + SetQueryParam("service", authorizationHeader.Service). + SetQueryParam("scope", authorizationHeader.Scope). + Get(authorizationHeader.Realm) + So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) - }) -} + var badToken authutils.AccessTokenResponse + err = json.Unmarshal(resp.Body(), &badToken) + So(err, ShouldBeNil) -func TestTLSMutualAuthAllowReadAccess(t *testing.T) { - Convey("Make a new controller", t, func() { - caCert, err := os.ReadFile(CACert) + resp, err = resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", badToken.AccessToken)). + Post(baseURL + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/") So(err, ShouldBeNil) - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) +} +func TestBearerAuthWrongAuthorizer(t *testing.T) { + Convey("Make a new authorizer", t, func() { port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - defer func() { resty.SetTLSClientConfig(nil) }() conf := config.New() conf.HTTP.Port = port - conf.HTTP.TLS = &config.TLSConfig{ - Cert: ServerCert, - Key: ServerKey, - CACert: CACert, - } - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - AnonymousPolicy: []string{"read"}, - }, + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Cert: "bla", + Realm: "blabla", + Service: "blablabla", }, } - ctlr := makeController(conf, t.TempDir()) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + So(func() { + api.AuthHandler(ctlr) + }, ShouldPanic) + }) +} - // accessing insecure HTTP site should fail - resp, err := resty.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) +func TestNewRelyingPartyOIDC(t *testing.T) { + Convey("Test NewRelyingPartyOIDC", t, func() { + conf := config.New() - // without client certs and creds, reads are allowed - resp, err = resty.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + mockOIDCServer, err := authutils.MockOIDCRun() + if err != nil { + panic(err) + } - // with creds but without certs, reads are allowed - resp, err = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + defer func() { + err := mockOIDCServer.Shutdown() + if err != nil { + panic(err) + } + }() - // without creds, writes should fail - resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + mockOIDCConfig := mockOIDCServer.Config() - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") - So(err, ShouldBeNil) + conf.HTTP.Auth = &config.AuthConfig{ + OpenID: &config.OpenIDConfig{ + Providers: map[string]config.OpenIDProviderConfig{ + "oidc": { + ClientID: mockOIDCConfig.ClientID, + ClientSecret: mockOIDCConfig.ClientSecret, + KeyPath: "", + Issuer: mockOIDCConfig.Issuer, + Scopes: []string{"openid", "email"}, + }, + }, + }, + } - resty.SetCertificates(cert) - defer func() { resty.SetCertificates(tls.Certificate{}) }() + Convey("provider not found in config", func() { + So(func() { _ = api.NewRelyingPartyOIDC(conf, "notDex") }, ShouldPanic) + }) - // with client certs but without creds, should succeed - resp, err = resty.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + Convey("key path not found on disk", func() { + oidcProviderCfg := conf.HTTP.Auth.OpenID.Providers["oidc"] + oidcProviderCfg.KeyPath = "path/to/file" + conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProviderCfg - // with client certs and creds, should get expected status code - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(func() { _ = api.NewRelyingPartyOIDC(conf, "oidc") }, ShouldPanic) + }) - // with client certs, creds shouldn't matter - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + Convey("https callback", func() { + conf.HTTP.TLS = &config.TLSConfig{ + Cert: ServerCert, + Key: ServerKey, + } + + rp := api.NewRelyingPartyOIDC(conf, "oidc") + So(rp, ShouldNotBeNil) + }) + + Convey("no client secret in config", func() { + oidcProvider := conf.HTTP.Auth.OpenID.Providers["oidc"] + oidcProvider.ClientSecret = "" + conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProvider + + rp := api.NewRelyingPartyOIDC(conf, "oidc") + So(rp, ShouldNotBeNil) + }) + + Convey("provider issuer unreachable", func() { + oidcProvider := conf.HTTP.Auth.OpenID.Providers["oidc"] + oidcProvider.Issuer = "" + conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProvider + + So(func() { _ = api.NewRelyingPartyOIDC(conf, "oidc") }, ShouldPanic) + }) }) } -func TestTLSMutualAndBasicAuth(t *testing.T) { - Convey("Make a new controller", t, func() { - caCert, err := os.ReadFile(CACert) - So(err, ShouldBeNil) - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) - +func TestInvalidCases(t *testing.T) { + Convey("Invalid repo dir", t, func() { port := test.GetFreePort() baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - defer func() { resty.SetTLSClientConfig(nil) }() conf := config.New() conf.HTTP.Port = port + htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) + + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ HTPasswd: config.AuthHTPasswd{ Path: htpasswdPath, }, } - conf.HTTP.TLS = &config.TLSConfig{ - Cert: ServerCert, - Key: ServerKey, - CACert: CACert, - } - ctlr := makeController(conf, t.TempDir()) + dir := t.TempDir() + ctlr := makeController(conf, dir) cm := test.NewControllerManager(ctlr) cm.StartAndWait(port) - defer cm.StopServer() - - // accessing insecure HTTP site should fail - resp, err := resty.R().Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + defer func(ctrl *api.Controller) { + err := os.Chmod(dir, 0o755) + if err != nil { + panic(err) + } - // without client certs and creds, should fail - _, err = resty.R().Get(secureBaseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + err = ctrl.Server.Shutdown(context.Background()) + if err != nil { + panic(err) + } - // with creds but without certs, should succeed - _, err = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + err = os.RemoveAll(ctrl.Config.Storage.RootDirectory) + if err != nil { + panic(err) + } + }(ctlr) - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") - So(err, ShouldBeNil) + err := os.Chmod(dir, 0o000) + if err != nil { + panic(err) + } - resty.SetCertificates(cert) - defer func() { resty.SetCertificates(tls.Certificate{}) }() + digest := godigest.FromString("dummy").String() + name := "zot-c-test" - // with client certs but without creds, should get access error - resp, err = resty.R().Get(secureBaseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + client := resty.New() - // with client certs and creds, should get expected status code - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + params := make(map[string]string) + params["from"] = "zot-cveid-test" + params["mount"] = digest - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + postResponse, err := client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(fmt.Sprintf("%s/v2/%s/blobs/uploads/", baseURL, name)) + So(err, ShouldBeNil) + So(postResponse.StatusCode(), ShouldEqual, http.StatusInternalServerError) }) } -func TestTLSMutualAndBasicAuthAllowReadAccess(t *testing.T) { - Convey("Make a new controller", t, func() { - caCert, err := os.ReadFile(CACert) - So(err, ShouldBeNil) - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) - +func TestCrossRepoMount(t *testing.T) { + Convey("Cross Repo Mount", t, func() { port := test.GetFreePort() baseURL := test.GetBaseURL(port) - secureBaseURL := test.GetSecureBaseURL(port) - resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12}) - defer func() { resty.SetTLSClientConfig(nil) }() conf := config.New() conf.HTTP.Port = port + htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) + + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ HTPasswd: config.AuthHTPasswd{ Path: htpasswdPath, }, } - conf.HTTP.TLS = &config.TLSConfig{ - Cert: ServerCert, - Key: ServerKey, - CACert: CACert, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - AnonymousPolicy: []string{"read"}, - }, - }, - } + dir := t.TempDir() + ctlr := api.NewController(conf) - ctlr := makeController(conf, t.TempDir()) + ctlr.Config.Storage.RootDirectory = dir + ctlr.Config.Storage.RemoteCache = false + ctlr.Config.Storage.Dedupe = false - cm := test.NewControllerManager(ctlr) + image := CreateDefaultImage() + err := WriteImageToFileSystem(image, "zot-cve-test", "test", storage.StoreController{ + DefaultStore: ociutils.GetDefaultImageStore(dir, ctlr.Log), + }) + So(err, ShouldBeNil) + + cm := test.NewControllerManager(ctlr) //nolint: varnamelen cm.StartAndWait(port) - defer cm.StopServer() - // accessing insecure HTTP site should fail - resp, err := resty.R().Get(baseURL) + params := make(map[string]string) + + manifestDigest := image.ManifestDescriptor.Digest + + dgst := manifestDigest + name := "zot-cve-test" + params["mount"] = string(manifestDigest) + params["from"] = name + + client := resty.New() + headResponse, err := client.R().SetBasicAuth(username, passphrase). + Head(fmt.Sprintf("%s/v2/%s/blobs/%s", baseURL, name, manifestDigest)) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + So(headResponse.StatusCode(), ShouldEqual, http.StatusOK) + + // All invalid request of mount should return 202. + params["mount"] = "sha:" - // without client certs and creds, should fail - _, err = resty.R().Get(secureBaseURL) + postResponse, err := client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/zot-c-test/blobs/uploads/") So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) + location, err := postResponse.RawResponse.Location() + So(err, ShouldBeNil) + So(location.String(), ShouldStartWith, fmt.Sprintf("%s%s/zot-c-test/%s/%s", + baseURL, constants.RoutePrefix, constants.Blobs, constants.Uploads)) + + incorrectParams := make(map[string]string) + incorrectParams["mount"] = godigest.FromString("dummy").String() + incorrectParams["from"] = "zot-x-test" - // with creds but without certs, should succeed - _, err = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(incorrectParams). + Post(baseURL + "/v2/zot-y-test/blobs/uploads/") So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) + So(test.Location(baseURL, postResponse), ShouldStartWith, fmt.Sprintf("%s%s/zot-y-test/%s/%s", + baseURL, constants.RoutePrefix, constants.Blobs, constants.Uploads)) - // setup TLS mutual auth - cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") + // Use correct request + // This is correct request but it will return 202 because blob is not present in cache. + params["mount"] = string(manifestDigest) + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/zot-c-test/blobs/uploads/") So(err, ShouldBeNil) + So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) + So(test.Location(baseURL, postResponse), ShouldStartWith, fmt.Sprintf("%s%s/zot-c-test/%s/%s", + baseURL, constants.RoutePrefix, constants.Blobs, constants.Uploads)) - resty.SetCertificates(cert) - defer func() { resty.SetCertificates(tls.Certificate{}) }() + // Send same request again + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/zot-c-test/blobs/uploads/") + So(err, ShouldBeNil) + So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - // with client certs but without creds, reads should succeed - resp, err = resty.R().Get(secureBaseURL + "/v2/") + // Valid requests + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/zot-d-test/blobs/uploads/") So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - // with only client certs, writes should fail - resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") + headResponse, err = client.R().SetBasicAuth(username, passphrase). + Head(fmt.Sprintf("%s/v2/zot-cv-test/blobs/%s", baseURL, manifestDigest)) So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + So(headResponse.StatusCode(), ShouldEqual, http.StatusNotFound) - // with client certs and creds, should get expected status code - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params).Post(baseURL + "/v2/zot-c-test/blobs/uploads/") + So(err, ShouldBeNil) + So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(secureBaseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - }) -} - -const ( - LDAPAddress = "127.0.0.1" - LDAPBaseDN = "ou=test" - LDAPBindDN = "cn=reader," + LDAPBaseDN - LDAPBindPassword = "bindPassword" -) - -type testLDAPServer struct { - server *vldap.Server - quitCh chan bool -} - -func newTestLDAPServer() *testLDAPServer { - ldaps := &testLDAPServer{} - quitCh := make(chan bool) - server := vldap.NewServer() - server.QuitChannel(quitCh) - server.BindFunc("", ldaps) - server.SearchFunc("", ldaps) - ldaps.server = server - ldaps.quitCh = quitCh - - return ldaps -} + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/ /blobs/uploads/") + So(err, ShouldBeNil) + So(postResponse.StatusCode(), ShouldEqual, http.StatusNotFound) -func (l *testLDAPServer) Start(port int) { - addr := fmt.Sprintf("%s:%d", LDAPAddress, port) + blob := manifestDigest.Encoded() - go func() { - if err := l.server.ListenAndServe(addr); err != nil { + buf, err := os.ReadFile(path.Join(ctlr.Config.Storage.RootDirectory, "zot-cve-test/blobs/sha256/"+blob)) + if err != nil { panic(err) } - }() - for { - _, err := net.Dial("tcp", addr) - if err == nil { - break - } + postResponse, err = client.R().SetHeader("Content-type", "application/octet-stream"). + SetBasicAuth(username, passphrase).SetQueryParam("digest", "sha256:"+blob). + SetBody(buf).Post(baseURL + "/v2/zot-d-test/blobs/uploads/") + So(err, ShouldBeNil) + So(postResponse.StatusCode(), ShouldEqual, http.StatusCreated) - time.Sleep(10 * time.Millisecond) - } -} + // We have uploaded a blob and since we have provided digest it should be full blob upload and there should be entry + // in cache, now try mount blob request status and it should be 201 because now blob is present in cache + // and it should do hard link. -func (l *testLDAPServer) Stop() { - l.quitCh <- true -} + // make a new server with dedupe on and same rootDir (can't restart because of metadb - boltdb being open) + newDir := t.TempDir() + err = test.CopyFiles(dir, newDir) + So(err, ShouldBeNil) -func (l *testLDAPServer) Bind(bindDN, bindSimplePw string, conn net.Conn) (vldap.LDAPResultCode, error) { - if bindDN == "" || bindSimplePw == "" { - return vldap.LDAPResultInappropriateAuthentication, errors.ErrRequireCred - } + cm.StopServer() - if (bindDN == LDAPBindDN && bindSimplePw == LDAPBindPassword) || - (bindDN == fmt.Sprintf("cn=%s,%s", username, LDAPBaseDN) && bindSimplePw == passphrase) { - return vldap.LDAPResultSuccess, nil - } + ctlr.Config.Storage.Dedupe = true + ctlr.Config.Storage.GC = false + ctlr.Config.Storage.RootDirectory = newDir + cm = test.NewControllerManager(ctlr) //nolint: varnamelen + cm.StartAndWait(port) + defer cm.StopServer() - return vldap.LDAPResultInvalidCredentials, errors.ErrInvalidCred -} + // wait for dedupe task to run + time.Sleep(10 * time.Second) -func (l *testLDAPServer) Search(boundDN string, req vldap.SearchRequest, - conn net.Conn, -) (vldap.ServerSearchResult, error) { - check := fmt.Sprintf("(uid=%s)", username) - if check == req.Filter { - return vldap.ServerSearchResult{ - Entries: []*vldap.Entry{ - { - DN: fmt.Sprintf("cn=%s,%s", username, LDAPBaseDN), - Attributes: []*vldap.EntryAttribute{ - { - Name: "memberOf", - Values: []string{group}, - }, - }, - }, - }, - ResultCode: vldap.LDAPResultSuccess, - }, nil - } + params["mount"] = string(manifestDigest) + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/zot-mount-test/blobs/uploads/") + So(err, ShouldBeNil) + So(postResponse.StatusCode(), ShouldEqual, http.StatusCreated) + So(test.Location(baseURL, postResponse), ShouldEqual, fmt.Sprintf("%s%s/zot-mount-test/%s/%s:%s", + baseURL, constants.RoutePrefix, constants.Blobs, godigest.SHA256, blob)) - return vldap.ServerSearchResult{}, nil -} + // Check os.SameFile here + cachePath := path.Join(ctlr.Config.Storage.RootDirectory, "zot-d-test", "blobs/sha256", dgst.Encoded()) -func TestBasicAuthWithLDAP(t *testing.T) { - Convey("Make a new controller", t, func() { - l := newTestLDAPServer() - port := test.GetFreePort() - ldapPort, err := strconv.Atoi(port) + cacheFi, err := os.Stat(cachePath) So(err, ShouldBeNil) - l.Start(ldapPort) - defer l.Stop() - port = test.GetFreePort() - baseURL := test.GetBaseURL(port) + linkPath := path.Join(ctlr.Config.Storage.RootDirectory, "zot-mount-test", "blobs/sha256", dgst.Encoded()) - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.Auth = &config.AuthConfig{ - LDAP: &config.LDAPConfig{ - Insecure: true, - Address: LDAPAddress, - Port: ldapPort, - BindDN: LDAPBindDN, - BindPassword: LDAPBindPassword, - BaseDN: LDAPBaseDN, - UserAttribute: "uid", - }, - } - ctlr := makeController(conf, t.TempDir()) + linkFi, err := os.Stat(linkPath) + So(err, ShouldBeNil) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + So(os.SameFile(cacheFi, linkFi), ShouldEqual, true) - // without creds, should get access error - resp, err := resty.R().Get(baseURL + "/v2/") + // Now try another mount request and this time it should be from above uploaded repo i.e zot-mount-test + // mount request should pass and should return 201. + params["mount"] = string(manifestDigest) + params["from"] = "zot-mount-test" + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/zot-mount1-test/blobs/uploads/") So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - var e apiErr.Error - err = json.Unmarshal(resp.Body(), &e) + So(postResponse.StatusCode(), ShouldEqual, http.StatusCreated) + So(test.Location(baseURL, postResponse), ShouldEqual, fmt.Sprintf("%s%s/zot-mount1-test/%s/%s:%s", + baseURL, constants.RoutePrefix, constants.Blobs, godigest.SHA256, blob)) + + linkPath = path.Join(ctlr.Config.Storage.RootDirectory, "zot-mount1-test", "blobs/sha256", dgst.Encoded()) + + linkFi, err = os.Stat(linkPath) So(err, ShouldBeNil) - // with creds, should get expected status code - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(baseURL) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(os.SameFile(cacheFi, linkFi), ShouldEqual, true) - resp, _ = resty.R().SetBasicAuth(username, passphrase).Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + headResponse, err = client.R().SetBasicAuth(username, passphrase). + Head(fmt.Sprintf("%s/v2/zot-cv-test/blobs/%s", baseURL, manifestDigest)) + So(err, ShouldBeNil) + So(headResponse.StatusCode(), ShouldEqual, http.StatusOK) - // missing password - resp, _ = resty.R().SetBasicAuth(username, "").Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) -} + // Invalid request + params = make(map[string]string) + params["mount"] = "sha256:" + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/zot-mount-test/blobs/uploads/") + So(err, ShouldBeNil) + So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) -func TestGroupsPermissionsForLDAP(t *testing.T) { - Convey("Make a new controller", t, func() { - l := newTestLDAPServer() - port := test.GetFreePort() - ldapPort, err := strconv.Atoi(port) + params = make(map[string]string) + params["from"] = "zot-cve-test" + postResponse, err = client.R(). + SetBasicAuth(username, passphrase).SetQueryParams(params). + Post(baseURL + "/v2/zot-mount-test/blobs/uploads/") So(err, ShouldBeNil) - l.Start(ldapPort) - defer l.Stop() + So(postResponse.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + }) - port = test.GetFreePort() + Convey("Disable dedupe and cache", t, func() { + port := test.GetFreePort() baseURL := test.GetBaseURL(port) - tempDir := t.TempDir() conf := config.New() conf.HTTP.Port = port + htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) + + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ - LDAP: &config.LDAPConfig{ - Insecure: true, - Address: LDAPAddress, - Port: ldapPort, - BindDN: LDAPBindDN, - BindPassword: LDAPBindPassword, - BaseDN: LDAPBaseDN, - UserAttribute: "uid", - UserGroupAttribute: "memberOf", + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, }, } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - group: { - Users: []string{username}, - }, - }, - Repositories: config.Repositories{ - repo: config.PolicyGroup{ - Policies: []config.Policy{ - { - Groups: []string{group}, - Actions: []string{"read", "create"}, - }, - }, - DefaultPolicy: []string{}, - }, - }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, - }, - } + dir := t.TempDir() - ctlr := makeController(conf, tempDir) + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = dir + ctlr.Config.Storage.Dedupe = false + ctlr.Config.Storage.GC = false - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + image := CreateImageWith().RandomLayers(1, 10).DefaultConfig().Build() - img := CreateDefaultImage() + err := WriteImageToFileSystem(image, "zot-cve-test", "0.0.1", + ociutils.GetDefaultStoreController(dir, ctlr.Log)) + So(err, ShouldBeNil) - err = UploadImageWithBasicAuth( - img, baseURL, repo, img.DigestStr(), - username, passphrase) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + // digest := test.GetTestBlobDigest("zot-cve-test", "layer").String() + digest := godigest.FromBytes(image.Layers[0]) + name := "zot-c-test" + client := resty.New() + headResponse, err := client.R().SetBasicAuth(username, passphrase). + Head(fmt.Sprintf("%s/v2/%s/blobs/%s", baseURL, name, digest)) So(err, ShouldBeNil) + So(headResponse.StatusCode(), ShouldEqual, http.StatusNotFound) }) } -func TestLDAPFailures(t *testing.T) { - Convey("Make a LDAP conn", t, func() { - l := newTestLDAPServer() - port := test.GetFreePort() - ldapPort, err := strconv.Atoi(port) - So(err, ShouldBeNil) - l.Start(ldapPort) - defer l.Stop() - - Convey("Empty config", func() { - lc := &api.LDAPClient{} - err := lc.Connect() - So(err, ShouldNotBeNil) - }) +func TestParallelRequests(t *testing.T) { + t.Parallel() - Convey("Basic connectivity config", func() { - lc := &api.LDAPClient{ - Host: LDAPAddress, - Port: ldapPort, - } - err := lc.Connect() - So(err, ShouldNotBeNil) - }) + testCases := []struct { + srcImageName string + srcImageTag string + destImageName string + destImageTag string + testCaseName string + }{ + { + srcImageName: "zot-test", + srcImageTag: "0.0.1", + destImageName: "zot-1-test", + destImageTag: "0.0.1", + testCaseName: "Request-1", + }, + { + srcImageName: "zot-test", + srcImageTag: "0.0.1", + destImageName: "zot-2-test", + testCaseName: "Request-2", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "a/zot-3-test", + testCaseName: "Request-3", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "b/zot-4-test", + testCaseName: "Request-4", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "zot-5-test", + testCaseName: "Request-5", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "zot-1-test", + testCaseName: "Request-6", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "zot-2-test", + testCaseName: "Request-7", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "zot-3-test", + testCaseName: "Request-8", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "zot-4-test", + testCaseName: "Request-9", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "zot-5-test", + testCaseName: "Request-10", + }, + { + srcImageName: "zot-test", + srcImageTag: "0.0.1", + destImageName: "zot-1-test", + destImageTag: "0.0.1", + testCaseName: "Request-11", + }, + { + srcImageName: "zot-test", + srcImageTag: "0.0.1", + destImageName: "zot-2-test", + testCaseName: "Request-12", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "a/zot-3-test", + testCaseName: "Request-13", + }, + { + srcImageName: "zot-cve-test", + srcImageTag: "0.0.1", + destImageName: "b/zot-4-test", + testCaseName: "Request-14", + }, + } - Convey("Basic TLS connectivity config", func() { - lc := &api.LDAPClient{ - Host: LDAPAddress, - Port: ldapPort, - UseSSL: true, - } - err := lc.Connect() - So(err, ShouldNotBeNil) - }) - }) -} + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) -func TestBearerAuth(t *testing.T) { - Convey("Make a new controller", t, func() { - authTestServer := authutils.MakeAuthTestServer(ServerKey, UnauthorizedNamespace) - defer authTestServer.Close() + conf := config.New() + conf.HTTP.Port = port + htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } - conf := config.New() - conf.HTTP.Port = port + dir := t.TempDir() + firstSubDir := t.TempDir() + secondSubDir := t.TempDir() - aurl, err := url.Parse(authTestServer.URL) - So(err, ShouldBeNil) + subPaths := make(map[string]config.StorageConfig) - conf.HTTP.Auth = &config.AuthConfig{ - Bearer: &config.BearerConfig{ - Cert: ServerCert, - Realm: authTestServer.URL + "/auth/token", - Service: aurl.Host, - }, - } - ctlr := makeController(conf, t.TempDir()) + subPaths["/a"] = config.StorageConfig{RootDirectory: firstSubDir} + subPaths["/b"] = config.StorageConfig{RootDirectory: secondSubDir} - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + ctlr := makeController(conf, dir) + ctlr.Config.Storage.SubPaths = subPaths - blob := []byte("hello, blob!") - digest := godigest.FromBytes(blob).String() + testImagesDir := t.TempDir() + testImagesController := ociutils.GetDefaultStoreController(testImagesDir, ctlr.Log) - resp, err := resty.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + err := WriteImageToFileSystem(CreateRandomImage(), "zot-test", "0.0.1", testImagesController) + assert.Equal(t, err, nil, "Error should be nil") - authorizationHeader := authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var goodToken authutils.AccessTokenResponse - err = json.Unmarshal(resp.Body(), &goodToken) - So(err, ShouldBeNil) + err = WriteImageToFileSystem(CreateRandomImage(), "zot-cve-test", "0.0.1", testImagesController) + assert.Equal(t, err, nil, "Error should be nil") - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) - resp, err = resty.R().SetHeader("Authorization", - fmt.Sprintf("Bearer %s", goodToken.AccessToken)).Options(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + // without creds, should get access error + for i, testcase := range testCases { + testcase := testcase + run := i - resp, err = resty.R().Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + t.Run(testcase.testCaseName, func(t *testing.T) { + t.Parallel() + client := resty.New() - authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = json.Unmarshal(resp.Body(), &goodToken) - So(err, ShouldBeNil) + tagResponse, err := client.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + testcase.destImageName + "/tags/list") + assert.Equal(t, err, nil, "Error should be nil") + assert.NotEqual(t, tagResponse.StatusCode(), http.StatusBadRequest, "bad request") - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := resp.Header().Get("Location") + manifestList := getAllManifests(path.Join(testImagesDir, testcase.srcImageName)) - resp, err = resty.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = json.Unmarshal(resp.Body(), &goodToken) - So(err, ShouldBeNil) + for _, manifest := range manifestList { + headResponse, err := client.R().SetBasicAuth(username, passphrase). + Head(baseURL + "/v2/" + testcase.destImageName + "/manifests/" + manifest) + assert.Equal(t, err, nil, "Error should be nil") + assert.Equal(t, headResponse.StatusCode(), http.StatusNotFound, "response status code should return 404") - resp, err = resty.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + getResponse, err := client.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + testcase.destImageName + "/manifests/" + manifest) + assert.Equal(t, err, nil, "Error should be nil") + assert.Equal(t, getResponse.StatusCode(), http.StatusNotFound, "response status code should return 404") + } - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - Get(baseURL + "/v2/" + AuthorizedNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + blobList := getAllBlobs(path.Join(testImagesDir, testcase.srcImageName)) - authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = json.Unmarshal(resp.Body(), &goodToken) - So(err, ShouldBeNil) + for _, blob := range blobList { + // Get request of blob + headResponse, err := client.R(). + SetBasicAuth(username, passphrase). + Head(baseURL + "/v2/" + testcase.destImageName + "/blobs/sha256:" + blob) - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - Get(baseURL + "/v2/" + AuthorizedNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + assert.Equal(t, err, nil, "Should not be nil") + assert.NotEqual(t, headResponse.StatusCode(), http.StatusInternalServerError, + "internal server error should not occurred") - resp, err = resty.R(). - Post(baseURL + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + getResponse, err := client.R(). + SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + testcase.destImageName + "/blobs/sha256:" + blob) - authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var badToken authutils.AccessTokenResponse - err = json.Unmarshal(resp.Body(), &badToken) - So(err, ShouldBeNil) + assert.Equal(t, err, nil, "Should not be nil") + assert.NotEqual(t, getResponse.StatusCode(), http.StatusInternalServerError, + "internal server error should not occurred") - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", badToken.AccessToken)). - Post(baseURL + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) -} + blobPath := path.Join(testImagesDir, testcase.srcImageName, "blobs/sha256", blob) -func TestBearerAuthWrongAuthorizer(t *testing.T) { - Convey("Make a new authorizer", t, func() { - port := test.GetFreePort() + buf, err := os.ReadFile(blobPath) + if err != nil { + panic(err) + } - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.Auth = &config.AuthConfig{ - Bearer: &config.BearerConfig{ - Cert: "bla", - Realm: "blabla", - Service: "blablabla", - }, - } - ctlr := makeController(conf, t.TempDir()) + // Post request of blob + postResponse, err := client.R(). + SetHeader("Content-type", "application/octet-stream"). + SetBasicAuth(username, passphrase). + SetBody(buf).Post(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/") - So(func() { - api.AuthHandler(ctlr) - }, ShouldPanic) - }) -} + assert.Equal(t, err, nil, "Error should be nil") + assert.NotEqual(t, postResponse.StatusCode(), http.StatusInternalServerError, + "response status code should not return 500") -func TestBearerAuthWithAllowReadAccess(t *testing.T) { - Convey("Make a new controller", t, func() { - authTestServer := authutils.MakeAuthTestServer(ServerKey, UnauthorizedNamespace) - defer authTestServer.Close() + // Post request with query parameter + if run%2 == 0 { + postResponse, err = client.R(). + SetHeader("Content-type", "application/octet-stream"). + SetBasicAuth(username, passphrase). + SetBody(buf). + Post(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/") - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + assert.Equal(t, err, nil, "Error should be nil") + assert.NotEqual(t, postResponse.StatusCode(), http.StatusInternalServerError, + "response status code should not return 500") - conf := config.New() - conf.HTTP.Port = port + var sessionID string + sessionIDList := postResponse.Header().Values("Blob-Upload-UUID") + if len(sessionIDList) == 0 { + location := postResponse.Header().Values("Location") + firstLocation := location[0] + splitLocation := strings.Split(firstLocation, "/") + sessionID = splitLocation[len(splitLocation)-1] + } else { + sessionID = sessionIDList[0] + } - aurl, err := url.Parse(authTestServer.URL) - So(err, ShouldBeNil) + file, err := os.Open(blobPath) + if err != nil { + panic(err) + } - conf.HTTP.Auth = &config.AuthConfig{ - Bearer: &config.BearerConfig{ - Cert: ServerCert, - Realm: authTestServer.URL + "/auth/token", - Service: aurl.Host, - }, - } - ctlr := makeController(conf, t.TempDir()) + defer file.Close() - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - AnonymousPolicy: []string{"read"}, - }, - }, - } + reader := bufio.NewReader(file) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + buf := make([]byte, 5*1024*1024) - blob := []byte("hello, blob!") - digest := godigest.FromBytes(blob).String() + if run%4 == 0 { + readContent := 0 + for { + nbytes, err := reader.Read(buf) + if err != nil { + if goerrors.Is(err, io.EOF) { + break + } + panic(err) + } + // Patch request of blob - resp, err := resty.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + patchResponse, err := client.R(). + SetBody(buf[0:nbytes]). + SetHeader("Content-Type", "application/octet-stream"). + SetHeader("Content-Length", fmt.Sprintf("%d", nbytes)). + SetHeader("Content-Range", fmt.Sprintf("%d", readContent)+"-"+fmt.Sprintf("%d", readContent+nbytes-1)). + SetBasicAuth(username, passphrase). + Patch(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/" + sessionID) - authorizationHeader := authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var goodToken authutils.AccessTokenResponse - err = json.Unmarshal(resp.Body(), &goodToken) - So(err, ShouldBeNil) + assert.Equal(t, err, nil, "Error should be nil") + assert.NotEqual(t, patchResponse.StatusCode(), http.StatusInternalServerError, + "response status code should not return 500") - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + readContent += nbytes + } + } else { + for { + nbytes, err := reader.Read(buf) + if err != nil { + if goerrors.Is(err, io.EOF) { + break + } + panic(err) + } + // Patch request of blob - resp, err = resty.R().Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + patchResponse, err := client.R().SetBody(buf[0:nbytes]).SetHeader("Content-type", "application/octet-stream"). + SetBasicAuth(username, passphrase). + Patch(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/" + sessionID) + if err != nil { + panic(err) + } - authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = json.Unmarshal(resp.Body(), &goodToken) - So(err, ShouldBeNil) - - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := resp.Header().Get("Location") - - resp, err = resty.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = json.Unmarshal(resp.Body(), &goodToken) - So(err, ShouldBeNil) + assert.Equal(t, err, nil, "Error should be nil") + assert.NotEqual(t, patchResponse.StatusCode(), http.StatusInternalServerError, + "response status code should not return 500") + } + } + } else { + postResponse, err = client.R(). + SetHeader("Content-type", "application/octet-stream"). + SetBasicAuth(username, passphrase). + SetBody(buf).SetQueryParam("digest", "sha256:"+blob). + Post(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/") - resp, err = resty.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + assert.Equal(t, err, nil, "Error should be nil") + assert.NotEqual(t, postResponse.StatusCode(), http.StatusInternalServerError, + "response status code should not return 500") + } - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - Get(baseURL + "/v2/" + AuthorizedNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + headResponse, err = client.R(). + SetBasicAuth(username, passphrase). + Head(baseURL + "/v2/" + testcase.destImageName + "/blobs/sha256:" + blob) - authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = json.Unmarshal(resp.Body(), &goodToken) - So(err, ShouldBeNil) + assert.Equal(t, err, nil, "Should not be nil") + assert.NotEqual(t, headResponse.StatusCode(), http.StatusInternalServerError, "response should return success code") - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", goodToken.AccessToken)). - Get(baseURL + "/v2/" + AuthorizedNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + getResponse, err = client.R(). + SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + testcase.destImageName + "/blobs/sha256:" + blob) - resp, err = resty.R(). - Post(baseURL + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + assert.Equal(t, err, nil, "Should not be nil") + assert.NotEqual(t, getResponse.StatusCode(), http.StatusInternalServerError, "response should return success code") + } - authorizationHeader = authutils.ParseBearerAuthHeader(resp.Header().Get("WWW-Authenticate")) - resp, err = resty.R(). - SetQueryParam("service", authorizationHeader.Service). - SetQueryParam("scope", authorizationHeader.Scope). - Get(authorizationHeader.Realm) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var badToken authutils.AccessTokenResponse - err = json.Unmarshal(resp.Body(), &badToken) - So(err, ShouldBeNil) + tagResponse, err = client.R().SetBasicAuth(username, passphrase). + Get(baseURL + "/v2/" + testcase.destImageName + "/tags/list") + assert.Equal(t, err, nil, "Error should be nil") + assert.Equal(t, tagResponse.StatusCode(), http.StatusOK, "response status code should return success code") - resp, err = resty.R(). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", badToken.AccessToken)). - Post(baseURL + "/v2/" + UnauthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) + repoResponse, err := client.R().SetBasicAuth(username, passphrase). + Get(baseURL + constants.RoutePrefix + constants.ExtCatalogPrefix) + assert.Equal(t, err, nil, "Error should be nil") + assert.Equal(t, repoResponse.StatusCode(), http.StatusOK, "response status code should return success code") + }) + } } -func TestNewRelyingPartyOIDC(t *testing.T) { - Convey("Test NewRelyingPartyOIDC", t, func() { +func TestHardLink(t *testing.T) { + Convey("Validate hard link", t, func() { + port := test.GetFreePort() conf := config.New() + conf.HTTP.Port = port - mockOIDCServer, err := authutils.MockOIDCRun() + dir := t.TempDir() + + err := os.Chmod(dir, 0o400) if err != nil { panic(err) } - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) - } - }() - - mockOIDCConfig := mockOIDCServer.Config() + subDir := t.TempDir() - conf.HTTP.Auth = &config.AuthConfig{ - OpenID: &config.OpenIDConfig{ - Providers: map[string]config.OpenIDProviderConfig{ - "oidc": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"openid", "email"}, - }, - }, - }, + err = os.Chmod(subDir, 0o400) + if err != nil { + panic(err) } - Convey("provider not found in config", func() { - So(func() { _ = api.NewRelyingPartyOIDC(conf, "notDex") }, ShouldPanic) - }) - - Convey("key path not found on disk", func() { - oidcProviderCfg := conf.HTTP.Auth.OpenID.Providers["oidc"] - oidcProviderCfg.KeyPath = "path/to/file" - conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProviderCfg - - So(func() { _ = api.NewRelyingPartyOIDC(conf, "oidc") }, ShouldPanic) - }) - - Convey("https callback", func() { - conf.HTTP.TLS = &config.TLSConfig{ - Cert: ServerCert, - Key: ServerKey, - } + ctlr := makeController(conf, dir) + subPaths := make(map[string]config.StorageConfig) - rp := api.NewRelyingPartyOIDC(conf, "oidc") - So(rp, ShouldNotBeNil) - }) + subPaths["/a"] = config.StorageConfig{RootDirectory: subDir, Dedupe: true} + ctlr.Config.Storage.SubPaths = subPaths - Convey("no client secret in config", func() { - oidcProvider := conf.HTTP.Auth.OpenID.Providers["oidc"] - oidcProvider.ClientSecret = "" - conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProvider + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - rp := api.NewRelyingPartyOIDC(conf, "oidc") - So(rp, ShouldNotBeNil) - }) + err = os.Chmod(dir, 0o644) + if err != nil { + panic(err) + } - Convey("provider issuer unreachable", func() { - oidcProvider := conf.HTTP.Auth.OpenID.Providers["oidc"] - oidcProvider.Issuer = "" - conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProvider + err = os.Chmod(subDir, 0o644) + if err != nil { + panic(err) + } - So(func() { _ = api.NewRelyingPartyOIDC(conf, "oidc") }, ShouldPanic) - }) + So(ctlr.Config.Storage.Dedupe, ShouldEqual, false) }) } -func TestOpenIDMiddleware(t *testing.T) { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - defaultVal := true +func TestImageSignatures(t *testing.T) { + Convey("Validate signatures", t, func() { + // start a new server + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port + conf := config.New() + conf.HTTP.Port = port - testCases := []struct { - testCaseName string - address string - externalURL string - }{ - { - address: "0.0.0.0", - externalURL: fmt.Sprintf("http://%s", net.JoinHostPort(conf.HTTP.Address, conf.HTTP.Port)), - testCaseName: "with ExternalURL provided in config", - }, - { - address: "127.0.0.1", - externalURL: "", - testCaseName: "without ExternalURL provided in config", - }, - } + dir := t.TempDir() + ctlr := makeController(conf, dir) + cm := test.NewControllerManager(ctlr) + // this blocks + cm.StartAndWait(port) + defer cm.StopServer() - // need a username different than ldap one, to test both logic - content := fmt.Sprintf("%s:$2y$05$hlbSXDp6hzDLu6VwACS39ORvVRpr3OMR4RlJ31jtlaOEGnPjKZI1m\n", htpasswdUsername) - htpasswdPath := test.MakeHtpasswdFileFromString(content) + repoName := "signed-repo" + img := CreateRandomImage() + content := img.ManifestDescriptor.Data + digest := img.ManifestDescriptor.Digest - defer os.Remove(htpasswdPath) + err := UploadImage(img, baseURL, repoName, "1.0") + So(err, ShouldBeNil) - ldapServer := newTestLDAPServer() - port = test.GetFreePort() + Convey("Validate cosign signatures", func() { + cwd, err := os.Getwd() + So(err, ShouldBeNil) + defer func() { _ = os.Chdir(cwd) }() + tdir := t.TempDir() + _ = os.Chdir(tdir) - ldapPort, err := strconv.Atoi(port) - if err != nil { - panic(err) - } + // generate a keypair + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) - ldapServer.Start(ldapPort) - defer ldapServer.Stop() + annotations := []string{"tag=1.0"} - mockOIDCServer, err := authutils.MockOIDCRun() - if err != nil { - panic(err) - } - - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) - } - }() - - mockOIDCConfig := mockOIDCServer.Config() - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - LDAP: &config.LDAPConfig{ - Insecure: true, - Address: LDAPAddress, - Port: ldapPort, - BindDN: LDAPBindDN, - BindPassword: LDAPBindPassword, - BaseDN: LDAPBaseDN, - UserAttribute: "uid", - }, - OpenID: &config.OpenIDConfig{ - Providers: map[string]config.OpenIDProviderConfig{ - "oidc": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"openid", "email"}, - }, - // just for the constructor coverage - "github": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"openid", "email"}, + // sign the image + err = sign.SignCmd(&options.RootOptions{Verbose: true, Timeout: 1 * time.Minute}, + options.KeyOpts{KeyRef: path.Join(tdir, "cosign.key"), PassFunc: generate.GetPass}, + options.SignOptions{ + Registry: options.RegistryOptions{AllowInsecure: true}, + AnnotationOptions: options.AnnotationOptions{Annotations: annotations}, + Upload: true, }, - }, - }, - } + []string{fmt.Sprintf("localhost:%s/%s@%s", port, repoName, digest.String())}) + So(err, ShouldBeNil) - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } + // verify the image + aopts := &options.AnnotationOptions{Annotations: annotations} + amap, err := aopts.AnnotationsMap() + So(err, ShouldBeNil) + vrfy := verify.VerifyCommand{ + RegistryOptions: options.RegistryOptions{AllowInsecure: true}, + CheckClaims: true, + KeyRef: path.Join(tdir, "cosign.pub"), + Annotations: amap, + IgnoreTlog: true, + } + err = vrfy.Exec(context.TODO(), []string{fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0")}) + So(err, ShouldBeNil) - // UI is enabled because we also want to test access on the mgmt route - uiConfig := &extconf.UIConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } + // verify the image with incorrect tag + aopts = &options.AnnotationOptions{Annotations: []string{"tag=2.0"}} + amap, err = aopts.AnnotationsMap() + So(err, ShouldBeNil) + vrfy = verify.VerifyCommand{ + RegistryOptions: options.RegistryOptions{AllowInsecure: true}, + CheckClaims: true, + KeyRef: path.Join(tdir, "cosign.pub"), + Annotations: amap, + IgnoreTlog: true, + } + err = vrfy.Exec(context.TODO(), []string{fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0")}) + So(err, ShouldNotBeNil) - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - UI: uiConfig, - } + // verify the image with incorrect key + aopts = &options.AnnotationOptions{Annotations: []string{"tag=1.0"}} + amap, err = aopts.AnnotationsMap() + So(err, ShouldBeNil) + vrfy = verify.VerifyCommand{ + CheckClaims: true, + RegistryOptions: options.RegistryOptions{AllowInsecure: true}, + KeyRef: path.Join(tdir, "cosign.key"), + Annotations: amap, + IgnoreTlog: true, + } + err = vrfy.Exec(context.TODO(), []string{fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0")}) + So(err, ShouldNotBeNil) - ctlr := api.NewController(conf) + // generate another keypair + err = os.Remove(path.Join(tdir, "cosign.pub")) + So(err, ShouldBeNil) + err = os.Remove(path.Join(tdir, "cosign.key")) + So(err, ShouldBeNil) - for _, testcase := range testCases { - t.Run(testcase.testCaseName, func(t *testing.T) { - Convey("make controller", t, func() { - dir := t.TempDir() - - ctlr.Config.Storage.RootDirectory = dir - ctlr.Config.HTTP.ExternalURL = testcase.externalURL - ctlr.Config.HTTP.Address = testcase.address - cm := test.NewControllerManager(ctlr) - - cm.StartServer() - defer cm.StopServer() - test.WaitTillServerReady(baseURL) - - Convey("browser client requests", func() { - Convey("login with no provider supplied", func() { - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "unknown"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - }) - - //nolint: dupl - Convey("make sure sessions are not used without UI header value", func() { - sessionsNo, err := getNumberOfSessions(conf.Storage.RootDirectory) - So(err, ShouldBeNil) - So(sessionsNo, ShouldEqual, 0) - - client := resty.New() - - // without header should not create session - resp, err := client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - sessionsNo, err = getNumberOfSessions(conf.Storage.RootDirectory) - So(err, ShouldBeNil) - So(sessionsNo, ShouldEqual, 0) - - client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) - - resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - sessionsNo, err = getNumberOfSessions(conf.Storage.RootDirectory) - So(err, ShouldBeNil) - So(sessionsNo, ShouldEqual, 1) - - // set cookies - client.SetCookies(resp.Cookies()) - - // should get same cookie - resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - sessionsNo, err = getNumberOfSessions(conf.Storage.RootDirectory) - So(err, ShouldBeNil) - So(sessionsNo, ShouldEqual, 1) - - resp, err = client.R(). - SetBasicAuth(htpasswdUsername, passphrase). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - client.SetCookies(resp.Cookies()) - - // call endpoint with session, without credentials, (added to client after previous request) - resp, err = client.R(). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - sessionsNo, err = getNumberOfSessions(conf.Storage.RootDirectory) - So(err, ShouldBeNil) - So(sessionsNo, ShouldEqual, 1) - }) - - Convey("login with openid and get catalog with session", func() { - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) - - Convey("with callback_ui value provided", func() { - // first login user - resp, err := client.R(). - SetQueryParam("provider", "oidc"). - SetQueryParam("callback_ui", baseURL+"/v2/"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - }) - - // first login user - resp, err := client.R(). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - client.SetCookies(resp.Cookies()) - - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // logout with options method for coverage - resp, err = client.R(). - Options(baseURL + constants.LogoutPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - - // logout user - resp, err = client.R(). - Post(baseURL + constants.LogoutPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // calling endpoint should fail with unauthorized access - resp, err = client.R(). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) - - //nolint: dupl - Convey("login with basic auth(htpasswd) and get catalog with session", func() { - client := resty.New() - client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) - - // without creds, should get access error - resp, err := client.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - var e apiErr.Error - err = json.Unmarshal(resp.Body(), &e) - So(err, ShouldBeNil) - - // first login user - // with creds, should get expected status code - resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R().SetBasicAuth(htpasswdUsername, passphrase).Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R(). - SetBasicAuth(htpasswdUsername, passphrase). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - client.SetCookies(resp.Cookies()) - - // call endpoint with session, without credentials, (added to client after previous request) - resp, err = client.R(). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R(). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // logout user - resp, err = client.R(). - Post(baseURL + constants.LogoutPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // calling endpoint should fail with unauthorized access - resp, err = client.R(). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) - - //nolint: dupl - Convey("login with ldap and get catalog", func() { - client := resty.New() - client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) - - // without creds, should get access error - resp, err := client.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - var e apiErr.Error - err = json.Unmarshal(resp.Body(), &e) - So(err, ShouldBeNil) - - // first login user - // with creds, should get expected status code - resp, err = client.R().SetBasicAuth(username, passphrase).Get(baseURL) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R().SetBasicAuth(username, passphrase).Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R(). - SetBasicAuth(username, passphrase). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - client.SetCookies(resp.Cookies()) - - // call endpoint with session, without credentials, (added to client after previous request) - resp, err = client.R(). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = client.R(). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // logout user - resp, err = client.R(). - Post(baseURL + constants.LogoutPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // calling endpoint should fail with unauthorized access - resp, err = client.R(). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) - - Convey("unauthenticated catalog request", func() { - client := resty.New() - - // mgmt should work both unauthenticated and authenticated - resp, err := client.R(). - Get(baseURL + constants.FullMgmt) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // call endpoint without session - resp, err = client.R(). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) - }) - }) + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) + + // verify the image with incorrect key + aopts = &options.AnnotationOptions{Annotations: []string{"tag=1.0"}} + amap, err = aopts.AnnotationsMap() + So(err, ShouldBeNil) + vrfy = verify.VerifyCommand{ + CheckClaims: true, + RegistryOptions: options.RegistryOptions{AllowInsecure: true}, + KeyRef: path.Join(tdir, "cosign.pub"), + Annotations: amap, + IgnoreTlog: true, + } + err = vrfy.Exec(context.TODO(), []string{fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0")}) + So(err, ShouldNotBeNil) }) - } -} -func TestIsOpenIDEnabled(t *testing.T) { - Convey("make oidc server", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + Convey("Validate notation signatures", func() { + cwd, err := os.Getwd() + So(err, ShouldBeNil) + defer func() { _ = os.Chdir(cwd) }() + tdir := t.TempDir() + _ = os.Chdir(tdir) - conf := config.New() - conf.HTTP.Port = port + signature.NotationPathLock.Lock() + defer signature.NotationPathLock.Unlock() - mockOIDCServer, err := authutils.MockOIDCRun() - if err != nil { - panic(err) - } + signature.LoadNotationPath(tdir) - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) - } - }() + err = signature.GenerateNotationCerts(tdir, "good") + So(err, ShouldBeNil) - rootDir := t.TempDir() - - Convey("Only OAuth2 provided", func() { - mockOIDCConfig := mockOIDCServer.Config() - conf.HTTP.Auth = &config.AuthConfig{ - OpenID: &config.OpenIDConfig{ - Providers: map[string]config.OpenIDProviderConfig{ - "github": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"email", "groups"}, - }, - }, - }, - } + err = signature.GenerateNotationCerts(tdir, "bad") + So(err, ShouldBeNil) - ctlr := api.NewController(conf) + image := fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0") + err = signature.SignWithNotation("good", image, tdir) + So(err, ShouldBeNil) - ctlr.Config.Storage.RootDirectory = rootDir + err = signature.VerifyWithNotation(image, tdir) + So(err, ShouldBeNil) - cm := test.NewControllerManager(ctlr) + // check list + sigs, err := signature.ListNotarySignatures(image, tdir) + So(len(sigs), ShouldEqual, 1) + So(err, ShouldBeNil) - cm.StartServer() - defer cm.StopServer() - test.WaitTillServerReady(baseURL) + // check unsupported manifest media type + resp, err := resty.R().SetHeader("Content-Type", "application/vnd.unsupported.image.manifest.v1+json"). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnsupportedMediaType) - resp, err := resty.R(). - Get(baseURL + "/v2/") + // check invalid content with artifact media type + resp, err = resty.R().SetHeader("Content-Type", artifactspec.MediaTypeArtifactManifest). + SetBody([]byte("bogus")).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - Convey("Unsupported provider", func() { - mockOIDCConfig := mockOIDCServer.Config() - conf.HTTP.Auth = &config.AuthConfig{ - OpenID: &config.OpenIDConfig{ - Providers: map[string]config.OpenIDProviderConfig{ - "invalidProvider": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"email", "groups"}, - }, - }, - }, - } + Convey("Validate corrupted signature", func() { + // verify with corrupted signature + resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( + fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var refs ispec.Index + err = json.Unmarshal(resp.Body(), &refs) + So(err, ShouldBeNil) + So(len(refs.Manifests), ShouldEqual, 1) + err = os.WriteFile(path.Join(dir, repoName, "blobs", + strings.ReplaceAll(refs.Manifests[0].Digest.String(), ":", "/")), []byte("corrupt"), 0o600) + So(err, ShouldBeNil) + resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( + fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - ctlr := api.NewController(conf) + err = signature.VerifyWithNotation(image, tdir) + So(err, ShouldNotBeNil) + }) - ctlr.Config.Storage.RootDirectory = rootDir + Convey("Validate deleted signature", func() { + // verify with corrupted signature + resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( + fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var refs ispec.Index + err = json.Unmarshal(resp.Body(), &refs) + So(err, ShouldBeNil) + So(len(refs.Manifests), ShouldEqual, 1) + err = os.Remove(path.Join(dir, repoName, "blobs", + strings.ReplaceAll(refs.Manifests[0].Digest.String(), ":", "/"))) + So(err, ShouldBeNil) + resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( + fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - cm := test.NewControllerManager(ctlr) + err = signature.VerifyWithNotation(image, tdir) + So(err, ShouldNotBeNil) + }) + }) - cm.StartServer() - defer cm.StopServer() - test.WaitTillServerReady(baseURL) + Convey("GetOrasReferrers", func() { + // cover error paths + resp, err := resty.R().Get( + fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, "badRepo", "badDigest")) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - // it will work because we have an invalid provider, and no other authn enabled, so no authn enabled - // normally an invalid provider will exit with error in cli validations - resp, err := resty.R(). - Get(baseURL + "/v2/") + resp, err = resty.R().Get( + fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, repoName, "badDigest")) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + resp, err = resty.R().Get( + fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + resp, err = resty.R().SetQueryParam("artifactType", "badArtifact").Get( + fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( + fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, "badRepo", digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) }) }) } -func TestAuthnSessionErrors(t *testing.T) { - Convey("make controller", t, func() { +func TestManifestValidation(t *testing.T) { + Convey("Validate manifest", t, func() { + // start a new server port := test.GetFreePort() baseURL := test.GetBaseURL(port) - defaultVal := true conf := config.New() conf.HTTP.Port = port - invalidSessionID := "sessionID" - - // need a username different than ldap one, to test both logic - content := fmt.Sprintf("%s:$2y$05$hlbSXDp6hzDLu6VwACS39ORvVRpr3OMR4RlJ31jtlaOEGnPjKZI1m\n", htpasswdUsername) - - htpasswdPath := test.MakeHtpasswdFileFromString(content) - defer os.Remove(htpasswdPath) - ldapServer := newTestLDAPServer() - port = test.GetFreePort() + dir := t.TempDir() + ctlr := makeController(conf, dir) + cm := test.NewControllerManager(ctlr) + // this blocks + cm.StartServer() + time.Sleep(1000 * time.Millisecond) + defer cm.StopServer() - ldapPort, err := strconv.Atoi(port) - if err != nil { - panic(err) - } + repoName := "validation" + blobContent := []byte("this is a blob") + blobDigest := godigest.FromBytes(blobContent) + So(blobDigest, ShouldNotBeNil) - ldapServer.Start(ldapPort) - defer ldapServer.Stop() + img := CreateRandomImage() + content := img.ManifestDescriptor.Data + digest := img.ManifestDescriptor.Digest + configDigest := img.ConfigDescriptor.Digest + configBlob := img.ConfigDescriptor.Data - mockOIDCServer, err := authutils.MockOIDCRun() - if err != nil { - panic(err) - } + err := UploadImage(img, baseURL, repoName, "1.0") + So(err, ShouldBeNil) - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) - } - }() - - rootDir := t.TempDir() - - mockOIDCConfig := mockOIDCServer.Config() - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - LDAP: &config.LDAPConfig{ - Insecure: true, - Address: LDAPAddress, - Port: ldapPort, - BindDN: LDAPBindDN, - BindPassword: LDAPBindPassword, - BaseDN: LDAPBaseDN, - UserAttribute: "uid", - }, - OpenID: &config.OpenIDConfig{ - Providers: map[string]config.OpenIDProviderConfig{ - "oidc": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"email", "groups"}, - }, + Convey("empty layers should pass validation", func() { + // create a manifest + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: int64(len(configBlob)), + Digest: configDigest, }, - }, - } - - uiConfig := &extconf.UIConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - conf.Extensions = &extconf.ExtensionConfig{ - UI: uiConfig, - Search: searchConfig, - } - - ctlr := api.NewController(conf) - - ctlr.Config.Storage.RootDirectory = rootDir - - cm := test.NewControllerManager(ctlr) - - cm.StartServer() - defer cm.StopServer() - test.WaitTillServerReady(baseURL) - - Convey("trigger basic authn middle(htpasswd) error", func() { - client := resty.New() - - ctlr.MetaDB = mocks.MetaDBMock{ - SetUserGroupsFn: func(ctx context.Context, groups []string) error { - return ErrUnexpectedError + Layers: []ispec.Descriptor{}, + Annotations: map[string]string{ + "key": "val", }, } + manifest.SchemaVersion = 2 - resp, err := client.R(). - SetBasicAuth(htpasswdUsername, passphrase). - Get(baseURL + "/v2/_catalog") + mcontent, err := json.Marshal(manifest) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) - Convey("trigger basic authn middle(ldap) error", func() { - client := resty.New() + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + }) - ctlr.MetaDB = mocks.MetaDBMock{ - SetUserGroupsFn: func(ctx context.Context, groups []string) error { - return ErrUnexpectedError + Convey("empty layers and schemaVersion missing should fail validation", func() { + // create a manifest + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: int64(len(configBlob)), + Digest: configDigest, + }, + Layers: []ispec.Descriptor{}, + Annotations: map[string]string{ + "key": "val", }, } - resp, err := client.R(). - SetBasicAuth(username, passphrase). - Get(baseURL + "/v2/_catalog") + mcontent, err := json.Marshal(manifest) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) - Convey("trigger updateUserData error", func() { - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) - ctlr.MetaDB = mocks.MetaDBMock{ - SetUserGroupsFn: func(ctx context.Context, groups []string) error { - return ErrUnexpectedError + Convey("missing layer should fail validation", func() { + missingLayer := []byte("missing layer") + missingLayerDigest := godigest.FromBytes(missingLayer) + // create a manifest + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: int64(len(configBlob)), + Digest: configDigest, + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest, + Size: int64(len(content)), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: missingLayerDigest, + Size: int64(len(missingLayer)), + }, + }, + Annotations: map[string]string{ + "key": "val", }, } + manifest.SchemaVersion = 2 - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) + mcontent, err := json.Marshal(manifest) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) - Convey("trigger session middle metaDB errors", func() { - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - - user := mockoidc.DefaultUser() - user.Groups = []string{"group1", "group2"} - - mockOIDCServer.QueueUser(user) - - ctlr.MetaDB = mocks.MetaDBMock{} - - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - Convey("trigger session middle error internal server error", func() { - cookies := resp.Cookies() - - client.SetCookies(cookies) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) - ctlr.MetaDB = mocks.MetaDBMock{ - GetUserGroupsFn: func(ctx context.Context) ([]string, error) { - return []string{}, ErrUnexpectedError + Convey("wrong mediatype should fail validation", func() { + // create a manifest + manifest := ispec.Manifest{ + MediaType: "bad.mediatype", + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: int64(len(configBlob)), + Digest: configDigest, + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest, + Size: int64(len(content)), }, - } - - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) - - Convey("trigger session middle error GetUserGroups not found", func() { - cookies := resp.Cookies() - - client.SetCookies(cookies) + }, + Annotations: map[string]string{ + "key": "val", + }, + } + manifest.SchemaVersion = 2 - ctlr.MetaDB = mocks.MetaDBMock{ - GetUserGroupsFn: func(ctx context.Context) ([]string, error) { - return []string{}, errors.ErrUserDataNotFound - }, - } + mcontent, err := json.Marshal(manifest) + So(err, ShouldBeNil) - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) }) - Convey("trigger no email error in routes(callback)", func() { - user := mockoidc.DefaultUser() - user.Email = "" - - mockOIDCServer.QueueUser(user) - - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + Convey("multiarch image should pass validation", func() { + index := ispec.Index{ + MediaType: ispec.MediaTypeImageIndex, + Manifests: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageManifest, + Digest: digest, + Size: int64(len((content))), + }, + }, + } - client.SetCookie(&http.Cookie{Name: "session"}) + index.SchemaVersion = 2 - // call endpoint with session (added to client after previous request) - resp, err := client.R(). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) + indexContent, err := json.Marshal(index) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) - Convey("trigger session save error in routes(callback)", func() { - err := os.Chmod(rootDir, 0o000) + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(indexContent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/index", repoName)) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + }) - defer func() { - err := os.Chmod(rootDir, storageConstants.DefaultDirPerms) - So(err, ShouldBeNil) - }() - - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + Convey("multiarch image without schemaVersion should fail validation", func() { + index := ispec.Index{ + MediaType: ispec.MediaTypeImageIndex, + Manifests: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageManifest, + Digest: digest, + Size: int64(len((content))), + }, + }, + } - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) + indexContent, err := json.Marshal(index) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) - Convey("trigger session save error in basicAuthn", func() { - err := os.Chmod(rootDir, 0o000) + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(indexContent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/index", repoName)) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) - defer func() { - err := os.Chmod(rootDir, storageConstants.DefaultDirPerms) - So(err, ShouldBeNil) - }() + Convey("multiarch image with missing manifest should fail validation", func() { + index := ispec.Index{ + MediaType: ispec.MediaTypeImageIndex, + Manifests: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageManifest, + Digest: digest, + Size: int64(len((content))), + }, + { + MediaType: ispec.MediaTypeImageManifest, + Digest: godigest.FromString("missing layer"), + Size: 10, + }, + }, + } - client := resty.New() - client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + index.SchemaVersion = 2 - // first htpasswd saveSessionLoggedUser() error - resp, err := client.R(). - SetBasicAuth(htpasswdUsername, passphrase). - Get(baseURL + "/v2/") + indexContent, err := json.Marshal(index) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - // second ldap saveSessionLoggedUser() error - resp, err = client.R(). - SetBasicAuth(username, passphrase). - Get(baseURL + "/v2/") + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(indexContent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/index", repoName)) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) }) + }) +} - Convey("trigger session middle errors", func() { - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - - user := mockoidc.DefaultUser() - user.Groups = []string{"group1", "group2"} +func TestArtifactReferences(t *testing.T) { + Convey("Validate Artifact References", t, func() { + // start a new server + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) - mockOIDCServer.QueueUser(user) + conf := config.New() + conf.HTTP.Port = port - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) + dir := t.TempDir() + ctlr := makeController(conf, dir) + cm := test.NewControllerManager(ctlr) + // this blocks + cm.StartServer() + time.Sleep(1000 * time.Millisecond) + defer cm.StopServer() + + repoName := "artifact-repo" + content := []byte("this is a blob") + digest := godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + + cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck + So(err, ShouldBeNil) + + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, "1.0") + So(err, ShouldBeNil) + + content, err = json.Marshal(manifest) + So(err, ShouldBeNil) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + + artifactType := "application/vnd.example.icecream.v1" + + Convey("Validate Image Manifest Reference", func() { + resp, err := resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + var referrers ispec.Index + err = json.Unmarshal(resp.Body(), &referrers) + So(err, ShouldBeNil) + So(referrers.Manifests, ShouldBeEmpty) + + // now upload a reference + + // upload image config blob + resp, err = resty.R().Post(baseURL + fmt.Sprintf("/v2/%s/blobs/uploads/", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := test.Location(baseURL, resp) + cblob, cdigest := getEmptyImageConfig() + + resp, err = resty.R(). + SetContentLength(true). + SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) + So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - Convey("trigger bad session encoding error in authn", func() { - cookies := resp.Cookies() - for _, cookie := range cookies { - if cookie.Name == "session" { - cookie.Value = "badSessionValue" - } - } + // create a manifest + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: artifactType, + Digest: cdigest, + Size: int64(len(cblob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest, + Size: int64(len(content)), + }, + }, + Subject: &ispec.Descriptor{ + MediaType: ispec.MediaTypeImageManifest, + Digest: digest, + Size: int64(len(content)), + }, + Annotations: map[string]string{ + "key": "val", + }, + } + manifest.SchemaVersion = 2 - client.SetCookies(cookies) + Convey("Using invalid content", func() { + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody([]byte("invalid data")).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") + // unknown repo will return status not found + resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", "unknown", digest.String())) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - Convey("web request without cookies", func() { - client.SetCookie(&http.Cookie{}) + resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") + resp, err = resty.R().SetQueryParams(map[string]string{"artifactType": artifactType}). + Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - Convey("web request with userless cookie", func() { - // first get session - session, err := ctlr.CookieStore.Get(resp.RawResponse.Request, "session") + // create a bad manifest (constructed manually) + content := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:71dbae9d7e6445fb5e0b11328e941b8e8937fdd52465079f536ce44bb78796ed","size":406}}` //nolint: lll + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - session.ID = invalidSessionID - session.IsNew = false - session.Values["authStatus"] = true + // missing layers + mcontent := []byte("this is a missing blob") + digest = godigest.FromBytes(mcontent) + So(digest, ShouldNotBeNil) - cookieStore, ok := ctlr.CookieStore.(*sessions.FilesystemStore) - So(ok, ShouldBeTrue) + manifest.Layers = append(manifest.Layers, ispec.Descriptor{ + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest, + Size: int64(len(mcontent)), + }) + + mcontent, err = json.Marshal(manifest) + So(err, ShouldBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - // first encode sessionID - encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, - cookieStore.Codecs...) + // invalid schema version + manifest.SchemaVersion = 1 + + mcontent, err = json.Marshal(manifest) So(err, ShouldBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - // save cookie - cookie := sessions.NewCookie(session.Name(), encoded, session.Options) - client.SetCookie(cookie) + // upload image config blob + resp, err = resty.R().Post(baseURL + fmt.Sprintf("/v2/%s/blobs/uploads/", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := test.Location(baseURL, resp) + cblob = []byte("{}") + cdigest = godigest.FromBytes(cblob) + So(cdigest, ShouldNotBeNil) - // encode session values and save on disk - encoded, err = securecookie.EncodeMulti(session.Name(), session.Values, - cookieStore.Codecs...) + resp, err = resty.R(). + SetContentLength(true). + SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), + }, + } - filename := filepath.Join(rootDir, "_sessions", "session_"+session.ID) + manifest.SchemaVersion = 2 + mcontent, err = json.Marshal(manifest) + So(err, ShouldBeNil) + digest = godigest.FromBytes(mcontent) + So(digest, ShouldNotBeNil) - err = os.WriteFile(filename, []byte(encoded), 0o600) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // missing layers + mcontent = []byte("this is a missing blob") + digest = godigest.FromBytes(mcontent) + So(digest, ShouldNotBeNil) + + manifest.Layers = append(manifest.Layers, ispec.Descriptor{ + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest, + Size: int64(len(mcontent)), + }) - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") + mcontent, err = json.Marshal(manifest) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) - Convey("web request with authStatus false cookie", func() { - // first get session - session, err := ctlr.CookieStore.Get(resp.RawResponse.Request, "session") + // should fail because config is of type image and blob is not uploaded + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // no layers at all + manifest.Layers = []ispec.Descriptor{} - session.ID = invalidSessionID - session.IsNew = false - session.Values["authStatus"] = false - session.Values["username"] = username + mcontent, err = json.Marshal(manifest) + So(err, ShouldBeNil) - cookieStore, ok := ctlr.CookieStore.(*sessions.FilesystemStore) - So(ok, ShouldBeTrue) + // should not fail + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + }) - // first encode sessionID - encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, - cookieStore.Codecs...) + Convey("Using valid content", func() { + content, err = json.Marshal(manifest) So(err, ShouldBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - // save cookie - cookie := sessions.NewCookie(session.Name(), encoded, session.Options) - client.SetCookie(cookie) + resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - // encode session values and save on disk - encoded, err = securecookie.EncodeMulti(session.Name(), session.Values, - cookieStore.Codecs...) + resp, err = resty.R().SetQueryParams(map[string]string{"artifact": "invalid"}). + Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - filename := filepath.Join(rootDir, "_sessions", "session_"+session.ID) + resp, err = resty.R().SetQueryParams(map[string]string{"artifactType": "invalid"}). + Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = os.WriteFile(filename, []byte(encoded), 0o600) + resp, err = resty.R().SetQueryParams(map[string]string{"artifactType": artifactType}). + Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Header().Get("Content-Type"), ShouldEqual, ispec.MediaTypeImageIndex) + So(resp.Header().Get("OCI-Filters-Applied"), ShouldEqual, "artifactType") - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") + resp, err = resty.R().SetQueryParams(map[string]string{"artifactType": artifactType + + ",otherArtType"}).Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, + digest.String())) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Header().Get("Content-Type"), ShouldEqual, ispec.MediaTypeImageIndex) + So(resp.Header().Get("OCI-Filters-Applied"), ShouldEqual, "artifactType") }) }) }) } -func TestAuthnMetaDBErrors(t *testing.T) { - Convey("make controller", t, func() { +//nolint:dupl // duplicated test code +func TestRouteFailures(t *testing.T) { + Convey("Make a new controller", t, func() { port := test.GetFreePort() baseURL := test.GetBaseURL(port) conf := config.New() conf.HTTP.Port = port - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) + ctlr := makeController(conf, t.TempDir()) + ctlr.Config.Storage.Commit = true - mockOIDCServer, err := authutils.MockOIDCRun() - if err != nil { - panic(err) - } + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) - } - }() + rthdlr := api.NewRouteHandler(ctlr) - rootDir := t.TempDir() + // NOTE: the url or method itself doesn't matter below since we are calling the handlers directly, + // so path routing is bypassed - mockOIDCConfig := mockOIDCServer.Config() - 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"}, - }, - }, - }, - } + Convey("List tags", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - ctlr := api.NewController(conf) + rthdlr.ListTags(response, request) - ctlr.Config.Storage.RootDirectory = rootDir + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - cm := test.NewControllerManager(ctlr) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - cm.StartServer() - defer cm.StopServer() - test.WaitTillServerReady(baseURL) + rthdlr.ListTags(response, request) - Convey("trigger basic authn middle(htpasswd) error", func() { - client := resty.New() + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - ctlr.MetaDB = mocks.MetaDBMock{ - SetUserGroupsFn: func(ctx context.Context, groups []string) error { - return ErrUnexpectedError - }, - } + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm := request.URL.Query() + qparm.Add("n", "a") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - resp, err := client.R(). - SetBasicAuth(username, passphrase). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) + rthdlr.ListTags(response, request) - Convey("trigger session middle metaDB errors", func() { - client := resty.New() - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - user := mockoidc.DefaultUser() - user.Groups = []string{"group1", "group2"} + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("n", "-1") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - mockOIDCServer.QueueUser(user) + rthdlr.ListTags(response, request) - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) + resp = response.Result() + defer resp.Body.Close() So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - Convey("trigger session middle error", func() { - cookies := resp.Cookies() + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("n", "abc") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - client.SetCookies(cookies) + rthdlr.ListTags(response, request) - ctlr.MetaDB = mocks.MetaDBMock{ - GetUserGroupsFn: func(ctx context.Context) ([]string, error) { - return []string{}, ErrUnexpectedError - }, - } + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - // call endpoint with session (added to client after previous request) - resp, err = client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) - }) - }) -} + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("n", "a") + qparm.Add("n", "abc") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() -func TestAuthorization(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - htpasswdPath := test.MakeHtpasswdFile() - defer os.Remove(htpasswdPath) + rthdlr.ListTags(response, request) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{}, - Actions: []string{}, - }, - }, - DefaultPolicy: []string{}, - }, - }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, - }, - } + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - Convey("with openid", func() { - mockOIDCServer, err := authutils.MockOIDCRun() - if err != nil { - panic(err) - } + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("n", "0") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) - } - }() - - mockOIDCConfig := mockOIDCServer.Config() - conf.HTTP.Auth = &config.AuthConfig{ - OpenID: &config.OpenIDConfig{ - Providers: map[string]config.OpenIDProviderConfig{ - "oidc": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"openid", "email"}, - }, - }, - }, - } + rthdlr.ListTags(response, request) - ctlr := api.NewController(conf) - ctlr.Config.Storage.RootDirectory = t.TempDir() + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - err = WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", - ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) - So(err, ShouldBeNil) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("n", "1") + qparm.Add("last", "") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + rthdlr.ListTags(response, request) - client := resty.New() + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - client.SetRedirectPolicy(test.CustomRedirectPolicy(20)) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("n", "1") + qparm.Add("last", "a") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - mockOIDCServer.QueueUser(&mockoidc.MockUser{ - Email: "test", - Subject: "1234567890", - }) + rthdlr.ListTags(response, request) - // first login user - resp, err := client.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) + resp = response.Result() + defer resp.Body.Close() So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - client.SetCookies(resp.Cookies()) - client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - RunAuthorizationTests(t, client, baseURL, conf) - }) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("last", "a") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - Convey("with basic auth", func() { - ctlr := api.NewController(conf) - ctlr.Config.Storage.RootDirectory = t.TempDir() + rthdlr.ListTags(response, request) - err := WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", - ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) - So(err, ShouldBeNil) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("n", "1") + qparm.Add("last", "a") + qparm.Add("last", "abc") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - client := resty.New() - client.SetBasicAuth(username, passphrase) + rthdlr.ListTags(response, request) - RunAuthorizationTests(t, client, baseURL, conf) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) }) - }) -} - -func TestGetUsername(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) - defer os.Remove(htpasswdPath) + Convey("Check manifest", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } + rthdlr.CheckManifest(response, request) - dir := t.TempDir() - ctlr := makeController(conf, dir) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - resp, err := resty.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + rthdlr.CheckManifest(response, request) - // test base64 encode - resp, err = resty.R().SetHeader("Authorization", "Basic should fail").Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - // test "username:password" encoding - resp, err = resty.R().SetHeader("Authorization", "Basic dGVzdA==").Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "reference": ""}) + response = httptest.NewRecorder() - // failed parsing authorization header - resp, err = resty.R().SetHeader("Authorization", "Basic ").Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + rthdlr.CheckManifest(response, request) - var e apiErr.Error - err = json.Unmarshal(resp.Body(), &e) - So(err, ShouldBeNil) - - resp, err = resty.R().SetBasicAuth(username, passphrase).Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - }) -} - -func TestAuthorizationWithOnlyAnonymousPolicy(t *testing.T) { - Convey("Make a new controller", t, func() { - const TestRepo = "my-repos/repo" - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.Auth = &config.AuthConfig{} - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - TestRepo: config.PolicyGroup{ - AnonymousPolicy: []string{"read"}, - }, - }, - } - - dir := t.TempDir() - ctlr := makeController(conf, dir) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - blob := []byte("hello, blob!") - digest := godigest.FromBytes(blob).String() + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) - resp, err := resty.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + Convey("Get manifest", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - resp, err = resty.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var e apiErr.Error - err = json.Unmarshal(resp.Body(), &e) - So(err, ShouldBeNil) + rthdlr.GetManifest(response, request) - // should get 401 without create - resp, err = resty.R().Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - if entry, ok := conf.HTTP.AccessControl.Repositories[TestRepo]; ok { - entry.AnonymousPolicy = []string{"create", "read"} - conf.HTTP.AccessControl.Repositories[TestRepo] = entry - } + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - // now it should get 202 - resp, err = resty.R().Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := resp.Header().Get("Location") + rthdlr.GetManifest(response, request) - // uploading blob should get 201 - resp, err = resty.R().SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - cblob, cdigest := GetRandomImageConfig() + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "reference": ""}) + response = httptest.NewRecorder() - resp, err = resty.R().Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc = test.Location(baseURL, resp) + rthdlr.GetManifest(response, request) - // uploading blob should get 201 - resp, err = resty.R().SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", cdigest.String()). - SetBody(cblob). - Put(loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) - manifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: "application/vnd.oci.image.config.v1+json", - Digest: cdigest, - Size: int64(len(cblob)), - }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: godigest.FromBytes(blob), - Size: int64(len(blob)), - }, - }, - } - manifest.SchemaVersion = 2 - manifestBlob, err := json.Marshal(manifest) - So(err, ShouldBeNil) + Convey("Update manifest", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - resp, err = resty.R(). - SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(manifestBlob). - Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + rthdlr.UpdateManifest(response, request) - updateBlob := []byte("Hello, blob update!") + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - resp, err = resty.R(). - Post(baseURL + "/v2/" + TestRepo + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc = test.Location(baseURL, resp) - // uploading blob should get 201 - resp, err = resty.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(updateBlob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", string(godigest.FromBytes(updateBlob))). - SetBody(updateBlob). - Put(loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - updatedManifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: "application/vnd.oci.image.config.v1+json", - Digest: cdigest, - Size: int64(len(cblob)), - }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: godigest.FromBytes(updateBlob), - Size: int64(len(updateBlob)), - }, - }, - } - updatedManifest.SchemaVersion = 2 - updatedManifestBlob, err := json.Marshal(updatedManifest) - So(err, ShouldBeNil) + rthdlr.UpdateManifest(response, request) - // update manifest should get 401 without update perm - resp, err = resty.R().SetBody(updatedManifestBlob). - Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - // get the manifest and check if it's the old one - resp, err = resty.R(). - Get(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldResemble, manifestBlob) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "reference": ""}) + response = httptest.NewRecorder() - // add update perm on repo - if entry, ok := conf.HTTP.AccessControl.Repositories[TestRepo]; ok { - entry.AnonymousPolicy = []string{"create", "read", "update"} - conf.HTTP.AccessControl.Repositories[TestRepo] = entry - } + rthdlr.UpdateManifest(response, request) - // update manifest should get 201 with update perm - resp, err = resty.R(). - SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(updatedManifestBlob). - Put(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) - // get the manifest and check if it's the new updated one - resp, err = resty.R(). - Get(baseURL + "/v2/" + TestRepo + "/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldResemble, updatedManifestBlob) + Convey("Delete manifest", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - resp, err = resty.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + rthdlr.DeleteManifest(response, request) - // make sure anonymous is correctly handled when using acCtx (requestcontext package) - catalog := struct { - Repositories []string `json:"repositories"` - }{} + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(len(catalog.Repositories), ShouldEqual, 1) - So(catalog.Repositories, ShouldContain, TestRepo) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - err = os.Mkdir(path.Join(dir, "zot-test"), storageConstants.DefaultDirPerms) - So(err, ShouldBeNil) + rthdlr.DeleteManifest(response, request) - err = WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "tag", ctlr.StoreController) - So(err, ShouldBeNil) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - // should not have read rights on zot-test - resp, err = resty.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "reference": ""}) + response = httptest.NewRecorder() - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(len(catalog.Repositories), ShouldEqual, 1) - So(catalog.Repositories, ShouldContain, TestRepo) + rthdlr.DeleteManifest(response, request) - // add rights - conf.HTTP.AccessControl.Repositories["zot-test"] = config.PolicyGroup{ - AnonymousPolicy: []string{"read"}, - } + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) - resp, err = resty.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + Convey("Check blob", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(len(catalog.Repositories), ShouldEqual, 2) - So(catalog.Repositories, ShouldContain, TestRepo) - So(catalog.Repositories, ShouldContain, "zot-test") - }) -} + rthdlr.CheckBlob(response, request) -func TestAuthorizationWithAnonymousPolicyBasicAuthAndSessionHeader(t *testing.T) { - Convey("Make a new controller", t, func() { - const TestRepo = "my-repos/repo" - const AllRepos = "**" - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - badpassphrase := "bad" - htpasswdContent := fmt.Sprintf("%s:$2y$05$hlbSXDp6hzDLu6VwACS39ORvVRpr3OMR4RlJ31jtlaOEGnPjKZI1m\n", - htpasswdUsername) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - htpasswdPath := test.MakeHtpasswdFileFromString(htpasswdContent) - defer os.Remove(htpasswdPath) + rthdlr.CheckBlob(response, request) - img := CreateRandomImage() - tagAnonymous := "1.0-anon" - tagAuth := "1.0-auth" - tagUnauth := "1.0-unauth" + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - conf := config.New() - conf.HTTP.Port = port - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AllRepos: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{htpasswdUsername}, - Actions: []string{"read"}, - }, - }, - AnonymousPolicy: []string{"read"}, - }, - }, - } - - dir := t.TempDir() - ctlr := makeController(conf, dir) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - // /v2 access - // Can access /v2 without credentials - resp, err := resty.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // Can access /v2 without credentials and with X-Zot-Api-Client=zot-ui - resp, err = resty.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // Can access /v2 with correct credentials - resp, err = resty.R(). - SetBasicAuth(htpasswdUsername, passphrase). - Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // Fail to access /v2 with incorrect credentials - resp, err = resty.R(). - SetBasicAuth(htpasswdUsername, badpassphrase). - Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - // Catalog access - resp, err = resty.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var apiError apiErr.Error - err = json.Unmarshal(resp.Body(), &apiError) - So(err, ShouldBeNil) - - resp, err = resty.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - apiError = apiErr.Error{} - err = json.Unmarshal(resp.Body(), &apiError) - So(err, ShouldBeNil) - - resp, err = resty.R(). - SetBasicAuth(htpasswdUsername, passphrase). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - apiError = apiErr.Error{} - err = json.Unmarshal(resp.Body(), &apiError) - So(err, ShouldBeNil) - - resp, err = resty.R(). - SetBasicAuth(htpasswdUsername, badpassphrase). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - apiError = apiErr.Error{} - err = json.Unmarshal(resp.Body(), &apiError) - So(err, ShouldBeNil) - - // upload capability - // should get 403 without create - err = UploadImage(img, baseURL, TestRepo, tagAnonymous) - So(err, ShouldNotBeNil) - - err = UploadImageWithBasicAuth(img, baseURL, - TestRepo, tagAuth, htpasswdUsername, passphrase) - So(err, ShouldNotBeNil) - - err = UploadImageWithBasicAuth(img, baseURL, - TestRepo, tagUnauth, htpasswdUsername, badpassphrase) - So(err, ShouldNotBeNil) - - if entry, ok := conf.HTTP.AccessControl.Repositories[AllRepos]; ok { - entry.AnonymousPolicy = []string{"create", "read"} - entry.Policies[0] = config.Policy{ - Users: []string{htpasswdUsername}, - Actions: []string{"create", "read"}, - } - conf.HTTP.AccessControl.Repositories[AllRepos] = entry - } - - // now it should succeed for valid users - err = UploadImage(img, baseURL, TestRepo, tagAnonymous) - So(err, ShouldBeNil) - - err = UploadImageWithBasicAuth(img, baseURL, - TestRepo, tagAuth, htpasswdUsername, passphrase) - So(err, ShouldBeNil) - - err = UploadImageWithBasicAuth(img, baseURL, - TestRepo, tagUnauth, htpasswdUsername, badpassphrase) - So(err, ShouldNotBeNil) - - // read capability - catalog := struct { - Repositories []string `json:"repositories"` - }{} - - resp, err = resty.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(len(catalog.Repositories), ShouldEqual, 1) - So(catalog.Repositories, ShouldContain, TestRepo) - - catalog = struct { - Repositories []string `json:"repositories"` - }{} - - resp, err = resty.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(len(catalog.Repositories), ShouldEqual, 1) - So(catalog.Repositories, ShouldContain, TestRepo) - - catalog = struct { - Repositories []string `json:"repositories"` - }{} - - resp, err = resty.R(). - SetBasicAuth(htpasswdUsername, passphrase). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(len(catalog.Repositories), ShouldEqual, 1) - So(catalog.Repositories, ShouldContain, TestRepo) - - resp, err = resty.R(). - SetBasicAuth(htpasswdUsername, badpassphrase). - Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }) -} - -func TestAuthorizationWithMultiplePolicies(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - // have two users: "test" user for user Policy, and "bob" for default policy - htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase) + - "\n" + getCredString("bob", passphrase)) - defer os.Remove(htpasswdPath) - - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - // config with all policy types, to test that the correct one is applied in each case - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{}, - Actions: []string{}, - }, - }, - DefaultPolicy: []string{}, - AnonymousPolicy: []string{}, - }, - }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, - }, - } - - Convey("with openid", func() { - dir := t.TempDir() - - mockOIDCServer, err := authutils.MockOIDCRun() - if err != nil { - panic(err) - } - - defer func() { - err := mockOIDCServer.Shutdown() - if err != nil { - panic(err) - } - }() - - mockOIDCConfig := mockOIDCServer.Config() - conf.HTTP.Auth = &config.AuthConfig{ - OpenID: &config.OpenIDConfig{ - Providers: map[string]config.OpenIDProviderConfig{ - "oidc": { - ClientID: mockOIDCConfig.ClientID, - ClientSecret: mockOIDCConfig.ClientSecret, - KeyPath: "", - Issuer: mockOIDCConfig.Issuer, - Scopes: []string{"openid", "email"}, - }, - }, - }, - } - - ctlr := api.NewController(conf) - ctlr.Config.Storage.RootDirectory = dir - - err = WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", - ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) - So(err, ShouldBeNil) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - testUserClient := resty.New() - - testUserClient.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - - mockOIDCServer.QueueUser(&mockoidc.MockUser{ - Email: "test", - Subject: "1234567890", - }) - - // first login user - resp, err := testUserClient.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - testUserClient.SetCookies(resp.Cookies()) - testUserClient.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) - - bobUserClient := resty.New() - - bobUserClient.SetRedirectPolicy(test.CustomRedirectPolicy(20)) - - mockOIDCServer.QueueUser(&mockoidc.MockUser{ - Email: "bob", - Subject: "1234567890", - }) - - // first login user - resp, err = bobUserClient.R(). - SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue). - SetQueryParam("provider", "oidc"). - Get(baseURL + constants.LoginPath) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - bobUserClient.SetCookies(resp.Cookies()) - bobUserClient.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue) - - RunAuthorizationWithMultiplePoliciesTests(t, testUserClient, bobUserClient, baseURL, conf) - }) - - Convey("with basic auth", func() { - dir := t.TempDir() - - ctlr := api.NewController(conf) - ctlr.Config.Storage.RootDirectory = dir - - err := WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", - ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) - So(err, ShouldBeNil) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - testUserClient := resty.New() - testUserClient.SetBasicAuth(username, passphrase) - - bobUserClient := resty.New() - bobUserClient.SetBasicAuth("bob", passphrase) - - RunAuthorizationWithMultiplePoliciesTests(t, testUserClient, bobUserClient, baseURL, conf) - }) - }) -} - -func TestInvalidCases(t *testing.T) { - Convey("Invalid repo dir", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) - - defer os.Remove(htpasswdPath) - - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - - dir := t.TempDir() - ctlr := makeController(conf, dir) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer func(ctrl *api.Controller) { - err := os.Chmod(dir, 0o755) - if err != nil { - panic(err) - } - - err = ctrl.Server.Shutdown(context.Background()) - if err != nil { - panic(err) - } - - err = os.RemoveAll(ctrl.Config.Storage.RootDirectory) - if err != nil { - panic(err) - } - }(ctlr) - - err := os.Chmod(dir, 0o000) - if err != nil { - panic(err) - } - - digest := godigest.FromString("dummy").String() - name := "zot-c-test" - - client := resty.New() - - params := make(map[string]string) - params["from"] = "zot-cveid-test" - params["mount"] = digest - - postResponse, err := client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(fmt.Sprintf("%s/v2/%s/blobs/uploads/", baseURL, name)) - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) -} - -func TestHTTPReadOnly(t *testing.T) { - Convey("Single cred", t, func() { - singleCredtests := []string{} - user := ALICE - password := ALICE - singleCredtests = append(singleCredtests, getCredString(user, password)) - singleCredtests = append(singleCredtests, getCredString(user, password)+"\n") - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - for _, testString := range singleCredtests { - func() { - conf := config.New() - conf.HTTP.Port = port - // enable read-only mode - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - DefaultPolicy: []string{"read"}, - }, - }, - } - - htpasswdPath := test.MakeHtpasswdFileFromString(testString) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - ctlr := makeController(conf, t.TempDir()) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - // with creds, should get expected status code - resp, _ := resty.R().SetBasicAuth(user, password).Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // with creds, any modifications should still fail on read-only mode - resp, err := resty.R().SetBasicAuth(user, password). - Post(baseURL + "/v2/" + AuthorizedNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - // with invalid creds, it should fail - resp, _ = resty.R().SetBasicAuth("chuck", "chuck").Get(baseURL + "/v2/") - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - }() - } - }) -} - -func TestCrossRepoMount(t *testing.T) { - Convey("Cross Repo Mount", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) - - defer os.Remove(htpasswdPath) - - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - - dir := t.TempDir() - ctlr := api.NewController(conf) - - ctlr.Config.Storage.RootDirectory = dir - ctlr.Config.Storage.RemoteCache = false - ctlr.Config.Storage.Dedupe = false - - image := CreateDefaultImage() - err := WriteImageToFileSystem(image, "zot-cve-test", "test", storage.StoreController{ - DefaultStore: ociutils.GetDefaultImageStore(dir, ctlr.Log), - }) - So(err, ShouldBeNil) - - cm := test.NewControllerManager(ctlr) //nolint: varnamelen - cm.StartAndWait(port) - - params := make(map[string]string) - - manifestDigest := image.ManifestDescriptor.Digest - - dgst := manifestDigest - name := "zot-cve-test" - params["mount"] = string(manifestDigest) - params["from"] = name - - client := resty.New() - headResponse, err := client.R().SetBasicAuth(username, passphrase). - Head(fmt.Sprintf("%s/v2/%s/blobs/%s", baseURL, name, manifestDigest)) - So(err, ShouldBeNil) - So(headResponse.StatusCode(), ShouldEqual, http.StatusOK) - - // All invalid request of mount should return 202. - params["mount"] = "sha:" - - postResponse, err := client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/zot-c-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - location, err := postResponse.RawResponse.Location() - So(err, ShouldBeNil) - So(location.String(), ShouldStartWith, fmt.Sprintf("%s%s/zot-c-test/%s/%s", - baseURL, constants.RoutePrefix, constants.Blobs, constants.Uploads)) - - incorrectParams := make(map[string]string) - incorrectParams["mount"] = godigest.FromString("dummy").String() - incorrectParams["from"] = "zot-x-test" - - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(incorrectParams). - Post(baseURL + "/v2/zot-y-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - So(test.Location(baseURL, postResponse), ShouldStartWith, fmt.Sprintf("%s%s/zot-y-test/%s/%s", - baseURL, constants.RoutePrefix, constants.Blobs, constants.Uploads)) - - // Use correct request - // This is correct request but it will return 202 because blob is not present in cache. - params["mount"] = string(manifestDigest) - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/zot-c-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - So(test.Location(baseURL, postResponse), ShouldStartWith, fmt.Sprintf("%s%s/zot-c-test/%s/%s", - baseURL, constants.RoutePrefix, constants.Blobs, constants.Uploads)) - - // Send same request again - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/zot-c-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - - // Valid requests - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/zot-d-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - - headResponse, err = client.R().SetBasicAuth(username, passphrase). - Head(fmt.Sprintf("%s/v2/zot-cv-test/blobs/%s", baseURL, manifestDigest)) - So(err, ShouldBeNil) - So(headResponse.StatusCode(), ShouldEqual, http.StatusNotFound) - - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params).Post(baseURL + "/v2/zot-c-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/ /blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusNotFound) - - blob := manifestDigest.Encoded() - - buf, err := os.ReadFile(path.Join(ctlr.Config.Storage.RootDirectory, "zot-cve-test/blobs/sha256/"+blob)) - if err != nil { - panic(err) - } - - postResponse, err = client.R().SetHeader("Content-type", "application/octet-stream"). - SetBasicAuth(username, passphrase).SetQueryParam("digest", "sha256:"+blob). - SetBody(buf).Post(baseURL + "/v2/zot-d-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusCreated) - - // We have uploaded a blob and since we have provided digest it should be full blob upload and there should be entry - // in cache, now try mount blob request status and it should be 201 because now blob is present in cache - // and it should do hard link. - - // make a new server with dedupe on and same rootDir (can't restart because of metadb - boltdb being open) - newDir := t.TempDir() - err = test.CopyFiles(dir, newDir) - So(err, ShouldBeNil) - - cm.StopServer() - - ctlr.Config.Storage.Dedupe = true - ctlr.Config.Storage.GC = false - ctlr.Config.Storage.RootDirectory = newDir - cm = test.NewControllerManager(ctlr) //nolint: varnamelen - cm.StartAndWait(port) - defer cm.StopServer() - - // wait for dedupe task to run - time.Sleep(10 * time.Second) - - params["mount"] = string(manifestDigest) - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/zot-mount-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusCreated) - So(test.Location(baseURL, postResponse), ShouldEqual, fmt.Sprintf("%s%s/zot-mount-test/%s/%s:%s", - baseURL, constants.RoutePrefix, constants.Blobs, godigest.SHA256, blob)) - - // Check os.SameFile here - cachePath := path.Join(ctlr.Config.Storage.RootDirectory, "zot-d-test", "blobs/sha256", dgst.Encoded()) - - cacheFi, err := os.Stat(cachePath) - So(err, ShouldBeNil) - - linkPath := path.Join(ctlr.Config.Storage.RootDirectory, "zot-mount-test", "blobs/sha256", dgst.Encoded()) - - linkFi, err := os.Stat(linkPath) - So(err, ShouldBeNil) - - So(os.SameFile(cacheFi, linkFi), ShouldEqual, true) - - // Now try another mount request and this time it should be from above uploaded repo i.e zot-mount-test - // mount request should pass and should return 201. - params["mount"] = string(manifestDigest) - params["from"] = "zot-mount-test" - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/zot-mount1-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusCreated) - So(test.Location(baseURL, postResponse), ShouldEqual, fmt.Sprintf("%s%s/zot-mount1-test/%s/%s:%s", - baseURL, constants.RoutePrefix, constants.Blobs, godigest.SHA256, blob)) - - linkPath = path.Join(ctlr.Config.Storage.RootDirectory, "zot-mount1-test", "blobs/sha256", dgst.Encoded()) - - linkFi, err = os.Stat(linkPath) - So(err, ShouldBeNil) - - So(os.SameFile(cacheFi, linkFi), ShouldEqual, true) - - headResponse, err = client.R().SetBasicAuth(username, passphrase). - Head(fmt.Sprintf("%s/v2/zot-cv-test/blobs/%s", baseURL, manifestDigest)) - So(err, ShouldBeNil) - So(headResponse.StatusCode(), ShouldEqual, http.StatusOK) - - // Invalid request - params = make(map[string]string) - params["mount"] = "sha256:" - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/zot-mount-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusAccepted) - - params = make(map[string]string) - params["from"] = "zot-cve-test" - postResponse, err = client.R(). - SetBasicAuth(username, passphrase).SetQueryParams(params). - Post(baseURL + "/v2/zot-mount-test/blobs/uploads/") - So(err, ShouldBeNil) - So(postResponse.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) - }) - - Convey("Disable dedupe and cache", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) - - defer os.Remove(htpasswdPath) - - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - - dir := t.TempDir() - - ctlr := api.NewController(conf) - ctlr.Config.Storage.RootDirectory = dir - ctlr.Config.Storage.Dedupe = false - ctlr.Config.Storage.GC = false - - image := CreateImageWith().RandomLayers(1, 10).DefaultConfig().Build() - - err := WriteImageToFileSystem(image, "zot-cve-test", "0.0.1", - ociutils.GetDefaultStoreController(dir, ctlr.Log)) - So(err, ShouldBeNil) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - // digest := test.GetTestBlobDigest("zot-cve-test", "layer").String() - digest := godigest.FromBytes(image.Layers[0]) - name := "zot-c-test" - client := resty.New() - headResponse, err := client.R().SetBasicAuth(username, passphrase). - Head(fmt.Sprintf("%s/v2/%s/blobs/%s", baseURL, name, digest)) - So(err, ShouldBeNil) - So(headResponse.StatusCode(), ShouldEqual, http.StatusNotFound) - }) -} - -func TestParallelRequests(t *testing.T) { - t.Parallel() - - testCases := []struct { - srcImageName string - srcImageTag string - destImageName string - destImageTag string - testCaseName string - }{ - { - srcImageName: "zot-test", - srcImageTag: "0.0.1", - destImageName: "zot-1-test", - destImageTag: "0.0.1", - testCaseName: "Request-1", - }, - { - srcImageName: "zot-test", - srcImageTag: "0.0.1", - destImageName: "zot-2-test", - testCaseName: "Request-2", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "a/zot-3-test", - testCaseName: "Request-3", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "b/zot-4-test", - testCaseName: "Request-4", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "zot-5-test", - testCaseName: "Request-5", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "zot-1-test", - testCaseName: "Request-6", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "zot-2-test", - testCaseName: "Request-7", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "zot-3-test", - testCaseName: "Request-8", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "zot-4-test", - testCaseName: "Request-9", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "zot-5-test", - testCaseName: "Request-10", - }, - { - srcImageName: "zot-test", - srcImageTag: "0.0.1", - destImageName: "zot-1-test", - destImageTag: "0.0.1", - testCaseName: "Request-11", - }, - { - srcImageName: "zot-test", - srcImageTag: "0.0.1", - destImageName: "zot-2-test", - testCaseName: "Request-12", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "a/zot-3-test", - testCaseName: "Request-13", - }, - { - srcImageName: "zot-cve-test", - srcImageTag: "0.0.1", - destImageName: "b/zot-4-test", - testCaseName: "Request-14", - }, - } - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - htpasswdPath := test.MakeHtpasswdFileFromString(getCredString(username, passphrase)) - - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - - dir := t.TempDir() - firstSubDir := t.TempDir() - secondSubDir := t.TempDir() - - subPaths := make(map[string]config.StorageConfig) - - subPaths["/a"] = config.StorageConfig{RootDirectory: firstSubDir} - subPaths["/b"] = config.StorageConfig{RootDirectory: secondSubDir} - - ctlr := makeController(conf, dir) - ctlr.Config.Storage.SubPaths = subPaths - - testImagesDir := t.TempDir() - testImagesController := ociutils.GetDefaultStoreController(testImagesDir, ctlr.Log) - - err := WriteImageToFileSystem(CreateRandomImage(), "zot-test", "0.0.1", testImagesController) - assert.Equal(t, err, nil, "Error should be nil") - - err = WriteImageToFileSystem(CreateRandomImage(), "zot-cve-test", "0.0.1", testImagesController) - assert.Equal(t, err, nil, "Error should be nil") - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - - // without creds, should get access error - for i, testcase := range testCases { - testcase := testcase - run := i - - t.Run(testcase.testCaseName, func(t *testing.T) { - t.Parallel() - client := resty.New() - - tagResponse, err := client.R().SetBasicAuth(username, passphrase). - Get(baseURL + "/v2/" + testcase.destImageName + "/tags/list") - assert.Equal(t, err, nil, "Error should be nil") - assert.NotEqual(t, tagResponse.StatusCode(), http.StatusBadRequest, "bad request") - - manifestList := getAllManifests(path.Join(testImagesDir, testcase.srcImageName)) - - for _, manifest := range manifestList { - headResponse, err := client.R().SetBasicAuth(username, passphrase). - Head(baseURL + "/v2/" + testcase.destImageName + "/manifests/" + manifest) - assert.Equal(t, err, nil, "Error should be nil") - assert.Equal(t, headResponse.StatusCode(), http.StatusNotFound, "response status code should return 404") - - getResponse, err := client.R().SetBasicAuth(username, passphrase). - Get(baseURL + "/v2/" + testcase.destImageName + "/manifests/" + manifest) - assert.Equal(t, err, nil, "Error should be nil") - assert.Equal(t, getResponse.StatusCode(), http.StatusNotFound, "response status code should return 404") - } - - blobList := getAllBlobs(path.Join(testImagesDir, testcase.srcImageName)) - - for _, blob := range blobList { - // Get request of blob - headResponse, err := client.R(). - SetBasicAuth(username, passphrase). - Head(baseURL + "/v2/" + testcase.destImageName + "/blobs/sha256:" + blob) - - assert.Equal(t, err, nil, "Should not be nil") - assert.NotEqual(t, headResponse.StatusCode(), http.StatusInternalServerError, - "internal server error should not occurred") - - getResponse, err := client.R(). - SetBasicAuth(username, passphrase). - Get(baseURL + "/v2/" + testcase.destImageName + "/blobs/sha256:" + blob) - - assert.Equal(t, err, nil, "Should not be nil") - assert.NotEqual(t, getResponse.StatusCode(), http.StatusInternalServerError, - "internal server error should not occurred") - - blobPath := path.Join(testImagesDir, testcase.srcImageName, "blobs/sha256", blob) - - buf, err := os.ReadFile(blobPath) - if err != nil { - panic(err) - } - - // Post request of blob - postResponse, err := client.R(). - SetHeader("Content-type", "application/octet-stream"). - SetBasicAuth(username, passphrase). - SetBody(buf).Post(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/") - - assert.Equal(t, err, nil, "Error should be nil") - assert.NotEqual(t, postResponse.StatusCode(), http.StatusInternalServerError, - "response status code should not return 500") - - // Post request with query parameter - if run%2 == 0 { - postResponse, err = client.R(). - SetHeader("Content-type", "application/octet-stream"). - SetBasicAuth(username, passphrase). - SetBody(buf). - Post(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/") - - assert.Equal(t, err, nil, "Error should be nil") - assert.NotEqual(t, postResponse.StatusCode(), http.StatusInternalServerError, - "response status code should not return 500") - - var sessionID string - sessionIDList := postResponse.Header().Values("Blob-Upload-UUID") - if len(sessionIDList) == 0 { - location := postResponse.Header().Values("Location") - firstLocation := location[0] - splitLocation := strings.Split(firstLocation, "/") - sessionID = splitLocation[len(splitLocation)-1] - } else { - sessionID = sessionIDList[0] - } - - file, err := os.Open(blobPath) - if err != nil { - panic(err) - } - - defer file.Close() - - reader := bufio.NewReader(file) - - buf := make([]byte, 5*1024*1024) - - if run%4 == 0 { - readContent := 0 - for { - nbytes, err := reader.Read(buf) - if err != nil { - if goerrors.Is(err, io.EOF) { - break - } - panic(err) - } - // Patch request of blob - - patchResponse, err := client.R(). - SetBody(buf[0:nbytes]). - SetHeader("Content-Type", "application/octet-stream"). - SetHeader("Content-Length", fmt.Sprintf("%d", nbytes)). - SetHeader("Content-Range", fmt.Sprintf("%d", readContent)+"-"+fmt.Sprintf("%d", readContent+nbytes-1)). - SetBasicAuth(username, passphrase). - Patch(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/" + sessionID) - - assert.Equal(t, err, nil, "Error should be nil") - assert.NotEqual(t, patchResponse.StatusCode(), http.StatusInternalServerError, - "response status code should not return 500") - - readContent += nbytes - } - } else { - for { - nbytes, err := reader.Read(buf) - if err != nil { - if goerrors.Is(err, io.EOF) { - break - } - panic(err) - } - // Patch request of blob - - patchResponse, err := client.R().SetBody(buf[0:nbytes]).SetHeader("Content-type", "application/octet-stream"). - SetBasicAuth(username, passphrase). - Patch(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/" + sessionID) - if err != nil { - panic(err) - } - - assert.Equal(t, err, nil, "Error should be nil") - assert.NotEqual(t, patchResponse.StatusCode(), http.StatusInternalServerError, - "response status code should not return 500") - } - } - } else { - postResponse, err = client.R(). - SetHeader("Content-type", "application/octet-stream"). - SetBasicAuth(username, passphrase). - SetBody(buf).SetQueryParam("digest", "sha256:"+blob). - Post(baseURL + "/v2/" + testcase.destImageName + "/blobs/uploads/") - - assert.Equal(t, err, nil, "Error should be nil") - assert.NotEqual(t, postResponse.StatusCode(), http.StatusInternalServerError, - "response status code should not return 500") - } - - headResponse, err = client.R(). - SetBasicAuth(username, passphrase). - Head(baseURL + "/v2/" + testcase.destImageName + "/blobs/sha256:" + blob) - - assert.Equal(t, err, nil, "Should not be nil") - assert.NotEqual(t, headResponse.StatusCode(), http.StatusInternalServerError, "response should return success code") - - getResponse, err = client.R(). - SetBasicAuth(username, passphrase). - Get(baseURL + "/v2/" + testcase.destImageName + "/blobs/sha256:" + blob) - - assert.Equal(t, err, nil, "Should not be nil") - assert.NotEqual(t, getResponse.StatusCode(), http.StatusInternalServerError, "response should return success code") - } - - tagResponse, err = client.R().SetBasicAuth(username, passphrase). - Get(baseURL + "/v2/" + testcase.destImageName + "/tags/list") - assert.Equal(t, err, nil, "Error should be nil") - assert.Equal(t, tagResponse.StatusCode(), http.StatusOK, "response status code should return success code") - - repoResponse, err := client.R().SetBasicAuth(username, passphrase). - Get(baseURL + constants.RoutePrefix + constants.ExtCatalogPrefix) - assert.Equal(t, err, nil, "Error should be nil") - assert.Equal(t, repoResponse.StatusCode(), http.StatusOK, "response status code should return success code") - }) - } -} - -func TestHardLink(t *testing.T) { - Convey("Validate hard link", t, func() { - port := test.GetFreePort() - conf := config.New() - conf.HTTP.Port = port - - dir := t.TempDir() - - err := os.Chmod(dir, 0o400) - if err != nil { - panic(err) - } - - subDir := t.TempDir() - - err = os.Chmod(subDir, 0o400) - if err != nil { - panic(err) - } - - ctlr := makeController(conf, dir) - subPaths := make(map[string]config.StorageConfig) - - subPaths["/a"] = config.StorageConfig{RootDirectory: subDir, Dedupe: true} - ctlr.Config.Storage.SubPaths = subPaths - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - err = os.Chmod(dir, 0o644) - if err != nil { - panic(err) - } - - err = os.Chmod(subDir, 0o644) - if err != nil { - panic(err) - } - - So(ctlr.Config.Storage.Dedupe, ShouldEqual, false) - }) -} - -func TestImageSignatures(t *testing.T) { - Convey("Validate signatures", t, func() { - // start a new server - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - - dir := t.TempDir() - ctlr := makeController(conf, dir) - cm := test.NewControllerManager(ctlr) - // this blocks - cm.StartAndWait(port) - defer cm.StopServer() - - repoName := "signed-repo" - img := CreateRandomImage() - content := img.ManifestDescriptor.Data - digest := img.ManifestDescriptor.Digest - - err := UploadImage(img, baseURL, repoName, "1.0") - So(err, ShouldBeNil) - - Convey("Validate cosign signatures", func() { - cwd, err := os.Getwd() - So(err, ShouldBeNil) - defer func() { _ = os.Chdir(cwd) }() - tdir := t.TempDir() - _ = os.Chdir(tdir) - - // generate a keypair - os.Setenv("COSIGN_PASSWORD", "") - err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) - So(err, ShouldBeNil) - - annotations := []string{"tag=1.0"} - - // sign the image - err = sign.SignCmd(&options.RootOptions{Verbose: true, Timeout: 1 * time.Minute}, - options.KeyOpts{KeyRef: path.Join(tdir, "cosign.key"), PassFunc: generate.GetPass}, - options.SignOptions{ - Registry: options.RegistryOptions{AllowInsecure: true}, - AnnotationOptions: options.AnnotationOptions{Annotations: annotations}, - Upload: true, - }, - []string{fmt.Sprintf("localhost:%s/%s@%s", port, repoName, digest.String())}) - So(err, ShouldBeNil) - - // verify the image - aopts := &options.AnnotationOptions{Annotations: annotations} - amap, err := aopts.AnnotationsMap() - So(err, ShouldBeNil) - vrfy := verify.VerifyCommand{ - RegistryOptions: options.RegistryOptions{AllowInsecure: true}, - CheckClaims: true, - KeyRef: path.Join(tdir, "cosign.pub"), - Annotations: amap, - IgnoreTlog: true, - } - err = vrfy.Exec(context.TODO(), []string{fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0")}) - So(err, ShouldBeNil) - - // verify the image with incorrect tag - aopts = &options.AnnotationOptions{Annotations: []string{"tag=2.0"}} - amap, err = aopts.AnnotationsMap() - So(err, ShouldBeNil) - vrfy = verify.VerifyCommand{ - RegistryOptions: options.RegistryOptions{AllowInsecure: true}, - CheckClaims: true, - KeyRef: path.Join(tdir, "cosign.pub"), - Annotations: amap, - IgnoreTlog: true, - } - err = vrfy.Exec(context.TODO(), []string{fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0")}) - So(err, ShouldNotBeNil) - - // verify the image with incorrect key - aopts = &options.AnnotationOptions{Annotations: []string{"tag=1.0"}} - amap, err = aopts.AnnotationsMap() - So(err, ShouldBeNil) - vrfy = verify.VerifyCommand{ - CheckClaims: true, - RegistryOptions: options.RegistryOptions{AllowInsecure: true}, - KeyRef: path.Join(tdir, "cosign.key"), - Annotations: amap, - IgnoreTlog: true, - } - err = vrfy.Exec(context.TODO(), []string{fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0")}) - So(err, ShouldNotBeNil) - - // generate another keypair - err = os.Remove(path.Join(tdir, "cosign.pub")) - So(err, ShouldBeNil) - err = os.Remove(path.Join(tdir, "cosign.key")) - So(err, ShouldBeNil) - - os.Setenv("COSIGN_PASSWORD", "") - err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) - So(err, ShouldBeNil) - - // verify the image with incorrect key - aopts = &options.AnnotationOptions{Annotations: []string{"tag=1.0"}} - amap, err = aopts.AnnotationsMap() - So(err, ShouldBeNil) - vrfy = verify.VerifyCommand{ - CheckClaims: true, - RegistryOptions: options.RegistryOptions{AllowInsecure: true}, - KeyRef: path.Join(tdir, "cosign.pub"), - Annotations: amap, - IgnoreTlog: true, - } - err = vrfy.Exec(context.TODO(), []string{fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0")}) - So(err, ShouldNotBeNil) - }) - - Convey("Validate notation signatures", func() { - cwd, err := os.Getwd() - So(err, ShouldBeNil) - defer func() { _ = os.Chdir(cwd) }() - tdir := t.TempDir() - _ = os.Chdir(tdir) - - signature.NotationPathLock.Lock() - defer signature.NotationPathLock.Unlock() - - signature.LoadNotationPath(tdir) - - err = signature.GenerateNotationCerts(tdir, "good") - So(err, ShouldBeNil) - - err = signature.GenerateNotationCerts(tdir, "bad") - So(err, ShouldBeNil) - - image := fmt.Sprintf("localhost:%s/%s:%s", port, repoName, "1.0") - err = signature.SignWithNotation("good", image, tdir) - So(err, ShouldBeNil) - - err = signature.VerifyWithNotation(image, tdir) - So(err, ShouldBeNil) - - // check list - sigs, err := signature.ListNotarySignatures(image, tdir) - So(len(sigs), ShouldEqual, 1) - So(err, ShouldBeNil) - - // check unsupported manifest media type - resp, err := resty.R().SetHeader("Content-Type", "application/vnd.unsupported.image.manifest.v1+json"). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnsupportedMediaType) - - // check invalid content with artifact media type - resp, err = resty.R().SetHeader("Content-Type", artifactspec.MediaTypeArtifactManifest). - SetBody([]byte("bogus")).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - Convey("Validate corrupted signature", func() { - // verify with corrupted signature - resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( - fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var refs ispec.Index - err = json.Unmarshal(resp.Body(), &refs) - So(err, ShouldBeNil) - So(len(refs.Manifests), ShouldEqual, 1) - err = os.WriteFile(path.Join(dir, repoName, "blobs", - strings.ReplaceAll(refs.Manifests[0].Digest.String(), ":", "/")), []byte("corrupt"), 0o600) - So(err, ShouldBeNil) - resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( - fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - - err = signature.VerifyWithNotation(image, tdir) - So(err, ShouldNotBeNil) - }) - - Convey("Validate deleted signature", func() { - // verify with corrupted signature - resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( - fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var refs ispec.Index - err = json.Unmarshal(resp.Body(), &refs) - So(err, ShouldBeNil) - So(len(refs.Manifests), ShouldEqual, 1) - err = os.Remove(path.Join(dir, repoName, "blobs", - strings.ReplaceAll(refs.Manifests[0].Digest.String(), ":", "/"))) - So(err, ShouldBeNil) - resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( - fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - err = signature.VerifyWithNotation(image, tdir) - So(err, ShouldNotBeNil) - }) - }) - - Convey("GetOrasReferrers", func() { - // cover error paths - resp, err := resty.R().Get( - fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, "badRepo", "badDigest")) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - resp, err = resty.R().Get( - fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, repoName, "badDigest")) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - resp, err = resty.R().Get( - fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - resp, err = resty.R().SetQueryParam("artifactType", "badArtifact").Get( - fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( - fmt.Sprintf("%s/oras/artifacts/v1/%s/manifests/%s/referrers", baseURL, "badRepo", digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - }) - }) -} - -func TestManifestValidation(t *testing.T) { - Convey("Validate manifest", t, func() { - // start a new server - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - - dir := t.TempDir() - ctlr := makeController(conf, dir) - cm := test.NewControllerManager(ctlr) - // this blocks - cm.StartServer() - time.Sleep(1000 * time.Millisecond) - defer cm.StopServer() - - repoName := "validation" - blobContent := []byte("this is a blob") - blobDigest := godigest.FromBytes(blobContent) - So(blobDigest, ShouldNotBeNil) - - img := CreateRandomImage() - content := img.ManifestDescriptor.Data - digest := img.ManifestDescriptor.Digest - configDigest := img.ConfigDescriptor.Digest - configBlob := img.ConfigDescriptor.Data - - err := UploadImage(img, baseURL, repoName, "1.0") - So(err, ShouldBeNil) - - Convey("empty layers should pass validation", func() { - // create a manifest - manifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: ispec.MediaTypeImageConfig, - Size: int64(len(configBlob)), - Digest: configDigest, - }, - Layers: []ispec.Descriptor{}, - Annotations: map[string]string{ - "key": "val", - }, - } - manifest.SchemaVersion = 2 - - mcontent, err := json.Marshal(manifest) - So(err, ShouldBeNil) - - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - }) - - Convey("empty layers and schemaVersion missing should fail validation", func() { - // create a manifest - manifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: ispec.MediaTypeImageConfig, - Size: int64(len(configBlob)), - Digest: configDigest, - }, - Layers: []ispec.Descriptor{}, - Annotations: map[string]string{ - "key": "val", - }, - } - - mcontent, err := json.Marshal(manifest) - So(err, ShouldBeNil) - - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - }) - - Convey("missing layer should fail validation", func() { - missingLayer := []byte("missing layer") - missingLayerDigest := godigest.FromBytes(missingLayer) - // create a manifest - manifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: ispec.MediaTypeImageConfig, - Size: int64(len(configBlob)), - Digest: configDigest, - }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: digest, - Size: int64(len(content)), - }, - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: missingLayerDigest, - Size: int64(len(missingLayer)), - }, - }, - Annotations: map[string]string{ - "key": "val", - }, - } - manifest.SchemaVersion = 2 - - mcontent, err := json.Marshal(manifest) - So(err, ShouldBeNil) - - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - }) - - Convey("wrong mediatype should fail validation", func() { - // create a manifest - manifest := ispec.Manifest{ - MediaType: "bad.mediatype", - Config: ispec.Descriptor{ - MediaType: ispec.MediaTypeImageConfig, - Size: int64(len(configBlob)), - Digest: configDigest, - }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: digest, - Size: int64(len(content)), - }, - }, - Annotations: map[string]string{ - "key": "val", - }, - } - manifest.SchemaVersion = 2 - - mcontent, err := json.Marshal(manifest) - So(err, ShouldBeNil) - - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - }) - - Convey("multiarch image should pass validation", func() { - index := ispec.Index{ - MediaType: ispec.MediaTypeImageIndex, - Manifests: []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageManifest, - Digest: digest, - Size: int64(len((content))), - }, - }, - } - - index.SchemaVersion = 2 - - indexContent, err := json.Marshal(index) - So(err, ShouldBeNil) - - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(indexContent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/index", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - }) - - Convey("multiarch image without schemaVersion should fail validation", func() { - index := ispec.Index{ - MediaType: ispec.MediaTypeImageIndex, - Manifests: []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageManifest, - Digest: digest, - Size: int64(len((content))), - }, - }, - } - - indexContent, err := json.Marshal(index) - So(err, ShouldBeNil) - - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(indexContent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/index", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - }) - - Convey("multiarch image with missing manifest should fail validation", func() { - index := ispec.Index{ - MediaType: ispec.MediaTypeImageIndex, - Manifests: []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageManifest, - Digest: digest, - Size: int64(len((content))), - }, - { - MediaType: ispec.MediaTypeImageManifest, - Digest: godigest.FromString("missing layer"), - Size: 10, - }, - }, - } - - index.SchemaVersion = 2 - - indexContent, err := json.Marshal(index) - So(err, ShouldBeNil) - - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(indexContent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/index", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - }) - }) -} - -func TestArtifactReferences(t *testing.T) { - Convey("Validate Artifact References", t, func() { - // start a new server - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - conf := config.New() - conf.HTTP.Port = port - - dir := t.TempDir() - ctlr := makeController(conf, dir) - cm := test.NewControllerManager(ctlr) - // this blocks - cm.StartServer() - time.Sleep(1000 * time.Millisecond) - defer cm.StopServer() - - repoName := "artifact-repo" - content := []byte("this is a blob") - digest := godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - - cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck - So(err, ShouldBeNil) - - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, "1.0") - So(err, ShouldBeNil) - - content, err = json.Marshal(manifest) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - - artifactType := "application/vnd.example.icecream.v1" - - Convey("Validate Image Manifest Reference", func() { - resp, err := resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - var referrers ispec.Index - err = json.Unmarshal(resp.Body(), &referrers) - So(err, ShouldBeNil) - So(referrers.Manifests, ShouldBeEmpty) - - // now upload a reference - - // upload image config blob - resp, err = resty.R().Post(baseURL + fmt.Sprintf("/v2/%s/blobs/uploads/", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := test.Location(baseURL, resp) - cblob, cdigest := getEmptyImageConfig() - - resp, err = resty.R(). - SetContentLength(true). - SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", cdigest.String()). - SetBody(cblob). - Put(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - // create a manifest - manifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: artifactType, - Digest: cdigest, - Size: int64(len(cblob)), - }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: digest, - Size: int64(len(content)), - }, - }, - Subject: &ispec.Descriptor{ - MediaType: ispec.MediaTypeImageManifest, - Digest: digest, - Size: int64(len(content)), - }, - Annotations: map[string]string{ - "key": "val", - }, - } - manifest.SchemaVersion = 2 - - Convey("Using invalid content", func() { - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody([]byte("invalid data")).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // unknown repo will return status not found - resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", "unknown", digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = resty.R().SetQueryParams(map[string]string{"artifactType": artifactType}). - Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // create a bad manifest (constructed manually) - content := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","subject":{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:71dbae9d7e6445fb5e0b11328e941b8e8937fdd52465079f536ce44bb78796ed","size":406}}` //nolint: lll - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // missing layers - mcontent := []byte("this is a missing blob") - digest = godigest.FromBytes(mcontent) - So(digest, ShouldNotBeNil) - - manifest.Layers = append(manifest.Layers, ispec.Descriptor{ - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: digest, - Size: int64(len(mcontent)), - }) - - mcontent, err = json.Marshal(manifest) - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - // invalid schema version - manifest.SchemaVersion = 1 - - mcontent, err = json.Marshal(manifest) - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // upload image config blob - resp, err = resty.R().Post(baseURL + fmt.Sprintf("/v2/%s/blobs/uploads/", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := test.Location(baseURL, resp) - cblob = []byte("{}") - cdigest = godigest.FromBytes(cblob) - So(cdigest, ShouldNotBeNil) - - resp, err = resty.R(). - SetContentLength(true). - SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", cdigest.String()). - SetBody(cblob). - Put(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - manifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: "application/vnd.oci.image.config.v1+json", - Digest: cdigest, - Size: int64(len(cblob)), - }, - } - - manifest.SchemaVersion = 2 - mcontent, err = json.Marshal(manifest) - So(err, ShouldBeNil) - digest = godigest.FromBytes(mcontent) - So(digest, ShouldNotBeNil) - - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // missing layers - mcontent = []byte("this is a missing blob") - digest = godigest.FromBytes(mcontent) - So(digest, ShouldNotBeNil) - - manifest.Layers = append(manifest.Layers, ispec.Descriptor{ - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: digest, - Size: int64(len(mcontent)), - }) - - mcontent, err = json.Marshal(manifest) - So(err, ShouldBeNil) - - // should fail because config is of type image and blob is not uploaded - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // no layers at all - manifest.Layers = []ispec.Descriptor{} - - mcontent, err = json.Marshal(manifest) - So(err, ShouldBeNil) - - // should not fail - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(mcontent).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - }) - - Convey("Using valid content", func() { - content, err = json.Marshal(manifest) - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/1.0", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = resty.R().SetQueryParams(map[string]string{"artifact": "invalid"}). - Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = resty.R().SetQueryParams(map[string]string{"artifactType": "invalid"}). - Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - resp, err = resty.R().SetQueryParams(map[string]string{"artifactType": artifactType}). - Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Header().Get("Content-Type"), ShouldEqual, ispec.MediaTypeImageIndex) - So(resp.Header().Get("OCI-Filters-Applied"), ShouldEqual, "artifactType") - - resp, err = resty.R().SetQueryParams(map[string]string{"artifactType": artifactType + - ",otherArtType"}).Get(baseURL + fmt.Sprintf("/v2/%s/referrers/%s", repoName, - digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Header().Get("Content-Type"), ShouldEqual, ispec.MediaTypeImageIndex) - So(resp.Header().Get("OCI-Filters-Applied"), ShouldEqual, "artifactType") - }) - }) - }) -} - -//nolint:dupl // duplicated test code -func TestRouteFailures(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - - ctlr := makeController(conf, t.TempDir()) - ctlr.Config.Storage.Commit = true - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - 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("List tags", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm := request.URL.Query() - qparm.Add("n", "a") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("n", "-1") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("n", "abc") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("n", "a") - qparm.Add("n", "abc") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("n", "0") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("n", "1") - qparm.Add("last", "") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("n", "1") - qparm.Add("last", "a") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("last", "a") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("n", "1") - qparm.Add("last", "a") - qparm.Add("last", "abc") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - }) - - Convey("Check manifest", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.CheckManifest(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.CheckManifest(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "reference": ""}) - response = httptest.NewRecorder() - - rthdlr.CheckManifest(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Get manifest", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.GetManifest(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.GetManifest(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "reference": ""}) - response = httptest.NewRecorder() - - rthdlr.GetManifest(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Update manifest", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.UpdateManifest(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.UpdateManifest(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "reference": ""}) - response = httptest.NewRecorder() - - rthdlr.UpdateManifest(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Delete manifest", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.DeleteManifest(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.DeleteManifest(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "reference": ""}) - response = httptest.NewRecorder() - - rthdlr.DeleteManifest(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Check blob", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.CheckBlob(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.CheckBlob(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "digest": ""}) - response = httptest.NewRecorder() - - rthdlr.CheckBlob(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Get blob", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.GetBlob(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.GetBlob(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "digest": ""}) - response = httptest.NewRecorder() - - rthdlr.GetBlob(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Delete blob", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.DeleteBlob(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.DeleteBlob(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "digest": ""}) - response = httptest.NewRecorder() - - rthdlr.DeleteBlob(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Create blob upload", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPost, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.CreateBlobUpload(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPost, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm := request.URL.Query() - qparm.Add("mount", "a") - qparm.Add("mount", "abc") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.CreateBlobUpload(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPost, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - qparm = request.URL.Query() - qparm.Add("mount", "a") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.CreateBlobUpload(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusAccepted) - }) - - Convey("Get blob upload", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.GetBlobUpload(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.GetBlobUpload(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Patch blob upload", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPatch, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.PatchBlobUpload(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.PatchBlobUpload(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Update blob upload", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.UpdateBlobUpload(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.UpdateBlobUpload(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "session_id": "bar"}) - response = httptest.NewRecorder() - - rthdlr.UpdateBlobUpload(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo", "session_id": "bar"}) - qparm := request.URL.Query() - qparm.Add("digest", "a") - qparm.Add("digest", "abc") - request.URL.RawQuery = qparm.Encode() - response = httptest.NewRecorder() - - rthdlr.UpdateBlobUpload(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - }) - - Convey("Delete blob upload", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.DeleteBlobUpload(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.DeleteBlobUpload(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - }) - - Convey("Get referrers", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{}) - response := httptest.NewRecorder() - - rthdlr.GetOrasReferrers(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - - request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "foo"}) - response = httptest.NewRecorder() - - rthdlr.GetOrasReferrers(response, request) - - resp = response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - }) - }) -} - -func TestListingTags(t *testing.T) { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - - ctlr := makeController(conf, t.TempDir()) - ctlr.Config.Storage.Commit = true - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - - defer cm.StopServer() - - rthdlr := api.NewRouteHandler(ctlr) - - img := CreateRandomImage() - sigTag := fmt.Sprintf("sha256-%s.sig", img.Digest().Encoded()) - - repoName := "test-tags" - tagsList := []string{ - "1", "2", "1.0.0", "new", "2.0.0", sigTag, - "2-test", "New", "2.0.0-test", "latest", - } - - for _, tag := range tagsList { - err := UploadImage(img, baseURL, repoName, tag) - if err != nil { - panic(err) - } - } - - // Note empty strings signify the query parameter is not set - // There are separate tests for passing the empty string as query parameter - testCases := []struct { - testCaseName string - pageSize string - last string - expectedTags []string - }{ - { - testCaseName: "Test tag soting order with no parameters", - pageSize: "", - last: "", - expectedTags: []string{ - "1", "1.0.0", "2", "2-test", "2.0.0", "2.0.0-test", - "New", "latest", "new", sigTag, - }, - }, - { - testCaseName: "Test with the parameter 'n' lower than total number of results", - pageSize: "5", - last: "", - expectedTags: []string{ - "1", "1.0.0", "2", "2-test", "2.0.0", - }, - }, - { - testCaseName: "Test with the parameter 'n' larger than total number of results", - pageSize: "50", - last: "", - expectedTags: []string{ - "1", "1.0.0", "2", "2-test", "2.0.0", "2.0.0-test", - "New", "latest", "new", sigTag, - }, - }, - { - testCaseName: "Test the parameter 'n' is 0", - pageSize: "0", - last: "", - expectedTags: []string{}, - }, - { - testCaseName: "Test the parameters 'n' and 'last'", - pageSize: "5", - last: "2-test", - expectedTags: []string{"2.0.0", "2.0.0-test", "New", "latest", "new"}, - }, - { - testCaseName: "Test the parameters 'n' and 'last' with `n` exceeding total number of results", - pageSize: "5", - last: "latest", - expectedTags: []string{"new", sigTag}, - }, - { - testCaseName: "Test the parameter 'n' and 'last' being second to last tag as value", - pageSize: "2", - last: "new", - expectedTags: []string{sigTag}, - }, - { - testCaseName: "Test the parameter 'last' without parameter 'n'", - pageSize: "", - last: "2", - expectedTags: []string{ - "2-test", "2.0.0", "2.0.0-test", - "New", "latest", "new", sigTag, - }, - }, - { - testCaseName: "Test the parameter 'last' with the final tag as value", - pageSize: "", - last: sigTag, - expectedTags: []string{}, - }, - { - testCaseName: "Test the parameter 'last' with the second to last tag as value", - pageSize: "", - last: "new", - expectedTags: []string{sigTag}, - }, - } - - for _, testCase := range testCases { - Convey(testCase.testCaseName, t, func() { - t.Log("Running " + testCase.testCaseName) - - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": repoName}) - - if testCase.pageSize != "" || testCase.last != "" { - qparm := request.URL.Query() - - if testCase.pageSize != "" { - qparm.Add("n", testCase.pageSize) - } - - if testCase.last != "" { - qparm.Add("last", testCase.last) - } - - request.URL.RawQuery = qparm.Encode() - } - - response := httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusOK) - - var tags common.ImageTags - err := json.NewDecoder(resp.Body).Decode(&tags) - So(err, ShouldBeNil) - So(tags.Tags, ShouldEqual, testCase.expectedTags) - - alltags := tagsList - sort.Strings(alltags) - - actualLinkValue := resp.Header.Get("Link") - if testCase.pageSize == "0" || testCase.pageSize == "" { //nolint:gocritic - So(actualLinkValue, ShouldEqual, "") - } else if testCase.expectedTags[len(testCase.expectedTags)-1] == alltags[len(alltags)-1] { - So(actualLinkValue, ShouldEqual, "") - } else { - expectedLinkValue := fmt.Sprintf("/v2/%s/tags/list?n=%s&last=%s; rel=\"next\"", - repoName, testCase.pageSize, tags.Tags[len(tags.Tags)-1], - ) - So(actualLinkValue, ShouldEqual, expectedLinkValue) - } - - t.Log("Finished " + testCase.testCaseName) - }) - } -} - -func TestStorageCommit(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - - dir := t.TempDir() - ctlr := makeController(conf, dir) - ctlr.Config.Storage.Commit = true - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - Convey("Manifests", func() { - _, _ = Print("\nManifests") - - cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck - So(err, ShouldBeNil) - - content := []byte("this is a blob5") - digest := godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - // check a non-existent manifest - resp, err := resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(content).Head(baseURL + "/v2/unknown/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - repoName := "repo7" - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, "test:1.0") - So(err, ShouldBeNil) - - _, err = os.Stat(path.Join(dir, "repo7")) - So(err, ShouldBeNil) - - content, err = json.Marshal(manifest) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(content).Put(baseURL + "/v2/repo7/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr := resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - - resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(content).Put(baseURL + "/v2/repo7/manifests/test:1.0.1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - - content, err = json.Marshal(manifest) - So(err, ShouldBeNil) - - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, "test:1.0.1") - So(err, ShouldBeNil) - - cfg, layers, manifest, err = deprecated.GetImageComponents(1) //nolint:staticcheck - So(err, ShouldBeNil) - - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, "test:2.0") - So(err, ShouldBeNil) - - resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(content).Put(baseURL + "/v2/repo7/manifests/test:2.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - - // check/get by tag - resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldNotBeEmpty) - // check/get by reference - resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldNotBeEmpty) - - // delete manifest by tag should pass - resp, err = resty.R().Delete(baseURL + "/v2/repo7/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - // delete manifest by digest (1.0 deleted but 1.0.1 has same reference) - resp, err = resty.R().Delete(baseURL + "/v2/repo7/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - // delete manifest by digest - resp, err = resty.R().Delete(baseURL + "/v2/repo7/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - // delete again should fail - resp, err = resty.R().Delete(baseURL + "/v2/repo7/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - // check/get by tag - resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/test:2.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/test:2.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - // check/get by reference - resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - }) - }) -} - -func TestManifestImageIndex(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - - dir := t.TempDir() - ctlr := makeController(conf, dir) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - rthdlr := api.NewRouteHandler(ctlr) - - cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck - So(err, ShouldBeNil) - - content := []byte("this is a blob1") - digest := godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - - // check a non-existent manifest - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Head(baseURL + "/v2/unknown/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - repoName := "index" - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, "test:1.0") - So(err, ShouldBeNil) - - _, err = os.Stat(path.Join(dir, "index")) - So(err, ShouldBeNil) - - content, err = json.Marshal(manifest) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - m1content := content - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Put(baseURL + "/v2/index/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr := resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - - // create another manifest but upload using its sha256 reference - - // upload image config blob - resp, err = resty.R().Post(baseURL + "/v2/index/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - - img := CreateRandomImage() - - err = UploadImage(img, baseURL, repoName, img.DigestStr()) - So(err, ShouldBeNil) - - content = img.ManifestDescriptor.Data - digest = img.ManifestDescriptor.Digest - - m2dgst := digest - m2size := len(content) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - - Convey("Image index", func() { - img := CreateRandomImage() - - err = UploadImage(img, baseURL, repoName, img.DigestStr()) - So(err, ShouldBeNil) - - content := img.ManifestDescriptor.Data - digest = img.ManifestDescriptor.Digest - - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr := resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - - var index ispec.Index - index.SchemaVersion = 2 - index.Manifests = []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageManifest, - Digest: digest, - Size: int64(len(content)), - }, - { - MediaType: ispec.MediaTypeImageManifest, - Digest: m2dgst, - Size: int64(m2size), - }, - } - - content, err = json.Marshal(index) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - index1dgst := digest - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(content).Put(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldNotBeEmpty) - So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - - img = CreateRandomImage() - - err = UploadImage(img, baseURL, repoName, img.DigestStr()) - So(err, ShouldBeNil) - - content = img.ManifestDescriptor.Data - digest = img.ManifestDescriptor.Digest - - m4dgst := digest - m4size := len(content) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - - index.SchemaVersion = 2 - index.Manifests = []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageManifest, - Digest: digest, - Size: int64(len(content)), - }, - { - MediaType: ispec.MediaTypeImageManifest, - Digest: m2dgst, - Size: int64(m2size), - }, - } - - content, err = json.Marshal(index) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(content).Put(baseURL + "/v2/index/manifests/test:index2") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index2") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldNotBeEmpty) - So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - - Convey("List tags", func() { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) - request = mux.SetURLVars(request, map[string]string{"name": "index"}) - response := httptest.NewRecorder() - - rthdlr.ListTags(response, request) - - resp := response.Result() - defer resp.Body.Close() - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusOK) - - var tags common.ImageTags - err = json.NewDecoder(resp.Body).Decode(&tags) - So(err, ShouldBeNil) - So(len(tags.Tags), ShouldEqual, 3) - So(tags.Tags, ShouldContain, "test:1.0") - So(tags.Tags, ShouldContain, "test:index1") - So(tags.Tags, ShouldContain, "test:index2") - }) - - Convey("Another index with same manifest", func() { - var index ispec.Index - index.SchemaVersion = 2 - index.Manifests = []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageManifest, - Digest: m4dgst, - Size: int64(m4size), - }, - } - - content, err = json.Marshal(index) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(content).Put(baseURL + "/v2/index/manifests/test:index3") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - }) - - Convey("Another index using digest with same manifest", func() { - var index ispec.Index - index.SchemaVersion = 2 - index.Manifests = []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageManifest, - Digest: m4dgst, - Size: int64(m4size), - }, - } - - content, err = json.Marshal(index) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - }) - - Convey("Deleting manifest contained by a multiarch image should not be allowed", func() { - resp, err = resty.R().Delete(baseURL + fmt.Sprintf("/v2/index/manifests/%s", m2dgst.String())) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) - }) - - Convey("Deleting an image index", func() { - // delete manifest by tag should pass - resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/test:index3") - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index3") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index2") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldNotBeEmpty) - So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - }) - - Convey("Deleting an image index by digest", func() { - // delete manifest by tag should pass - resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/test:index3") - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index3") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - resp, err = resty.R().Delete(baseURL + fmt.Sprintf("/v2/index/manifests/%s", index1dgst)) - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index2") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldNotBeEmpty) - So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - }) - - Convey("Update an index tag with different manifest", func() { - img := CreateRandomImage() - - err = UploadImage(img, baseURL, repoName, img.DigestStr()) - So(err, ShouldBeNil) - - content = img.ManifestDescriptor.Data - digest = img.ManifestDescriptor.Digest - - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - - index.SchemaVersion = 2 - index.Manifests = []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageManifest, - Digest: digest, - Size: int64(len(content)), - }, - } - - content, err = json.Marshal(index) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(content).Put(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - digestHdr = resp.Header().Get(constants.DistContentDigestKey) - So(digestHdr, ShouldNotBeEmpty) - So(digestHdr, ShouldEqual, digest.String()) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldNotBeEmpty) - So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - - // delete manifest by tag should pass - resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - }) - - Convey("Negative test cases", func() { - Convey("Delete index", func() { - err = os.Remove(path.Join(dir, "index", "blobs", index1dgst.Algorithm().String(), index1dgst.Encoded())) - So(err, ShouldBeNil) - resp, err = resty.R().Delete(baseURL + fmt.Sprintf("/v2/index/manifests/%s", index1dgst)) - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - So(resp.Body(), ShouldNotBeEmpty) - }) - - Convey("Corrupt index", func() { - err = os.WriteFile(path.Join(dir, "index", "blobs", index1dgst.Algorithm().String(), index1dgst.Encoded()), - []byte("deadbeef"), storageConstants.DefaultFilePerms) - So(err, ShouldBeNil) - resp, err = resty.R().Delete(baseURL + fmt.Sprintf("/v2/index/manifests/%s", index1dgst)) - So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - So(resp.Body(), ShouldBeEmpty) - }) - - Convey("Change media-type", func() { - // previously a manifest, try writing an image index - var index ispec.Index - index.SchemaVersion = 2 - index.Manifests = []ispec.Descriptor{ - { - MediaType: ispec.MediaTypeImageIndex, - Digest: m4dgst, - Size: int64(m4size), - }, - } - - content, err = json.Marshal(index) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(content).Put(baseURL + "/v2/index/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - - // previously an image index, try writing a manifest - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(m1content).Put(baseURL + "/v2/index/manifests/test:index1") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - }) - }) - }) - }) -} - -func TestManifestCollision(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - - dir := t.TempDir() - ctlr := makeController(conf, dir) - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - AuthorizationAllRepos: config.PolicyGroup{ - AnonymousPolicy: []string{ - constants.ReadPermission, - constants.CreatePermission, - constants.DeletePermission, - constants.DetectManifestCollisionPermission, - }, - }, - }, - } - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck - So(err, ShouldBeNil) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "digest": ""}) + response = httptest.NewRecorder() - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, "index", "test:1.0") - So(err, ShouldBeNil) + rthdlr.CheckBlob(response, request) - _, err = os.Stat(path.Join(dir, "index")) - So(err, ShouldBeNil) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) - content := []byte("this is a blob1") - digest := godigest.FromBytes(content) - So(digest, ShouldNotBeNil) + Convey("Get blob", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - // check a non-existent manifest - resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). - SetBody(content).Head(baseURL + "/v2/unknown/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + rthdlr.GetBlob(response, request) - content, err = json.Marshal(manifest) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, "index", "test:2.0") - So(err, ShouldBeNil) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - // Deletion should fail if using digest - resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusConflict) + rthdlr.GetBlob(response, request) - // remove detectManifestCollision action from ** (all repos) - repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] - repoPolicy.AnonymousPolicy = []string{"read", "delete"} - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/" + digest.String()) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "digest": ""}) + response = httptest.NewRecorder() - resp, err = resty.R().Get(baseURL + "/v2/index/manifests/test:1.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + rthdlr.GetBlob(response, request) - resp, err = resty.R().Get(baseURL + "/v2/index/manifests/test:2.0") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - }) -} + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) -func TestPullRange(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port + Convey("Delete blob", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - dir := t.TempDir() - ctlr := makeController(conf, dir) + rthdlr.DeleteBlob(response, request) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - // create a blob/layer - resp, err := resty.R().Post(baseURL + "/v2/index/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := test.Location(baseURL, resp) - So(loc, ShouldNotBeEmpty) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - // since we are not specifying any prefix i.e provided in config while starting server, - // so it should store index1 to global root dir - _, err = os.Stat(path.Join(dir, "index")) - So(err, ShouldBeNil) + rthdlr.DeleteBlob(response, request) - resp, err = resty.R().Get(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) - content := []byte("0123456789") - digest := godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - // monolithic blob upload: success - resp, err = resty.R().SetQueryParam("digest", digest.String()). - SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - blobLoc := resp.Header().Get("Location") - So(blobLoc, ShouldNotBeEmpty) - So(resp.Header().Get("Content-Length"), ShouldEqual, "0") - So(resp.Header().Get(constants.DistContentDigestKey), ShouldNotBeEmpty) - blobLoc = baseURL + blobLoc + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - Convey("Range is supported using 'bytes'", func() { - resp, err = resty.R().Head(blobLoc) - So(err, ShouldBeNil) - So(resp.Header().Get("Accept-Ranges"), ShouldEqual, "bytes") - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - }) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "digest": ""}) + response = httptest.NewRecorder() - Convey("Get a range of bytes", func() { - resp, err = resty.R().SetHeader("Range", "bytes=0-").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) - So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content))) - So(resp.Body(), ShouldResemble, content) + rthdlr.DeleteBlob(response, request) - resp, err = resty.R().SetHeader("Range", "bytes=0-100").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) - So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content))) - So(resp.Body(), ShouldResemble, content) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) - resp, err = resty.R().SetHeader("Range", "bytes=0-10").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) - So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content))) - So(resp.Body(), ShouldResemble, content) + Convey("Create blob upload", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPost, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - resp, err = resty.R().SetHeader("Range", "bytes=0-0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) - So(resp.Header().Get("Content-Length"), ShouldEqual, "1") - So(resp.Body(), ShouldResemble, content[0:1]) + rthdlr.CreateBlobUpload(response, request) - resp, err = resty.R().SetHeader("Range", "bytes=0-1").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) - So(resp.Header().Get("Content-Length"), ShouldEqual, "2") - So(resp.Body(), ShouldResemble, content[0:2]) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - resp, err = resty.R().SetHeader("Range", "bytes=2-3").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) - So(resp.Header().Get("Content-Length"), ShouldEqual, "2") - So(resp.Body(), ShouldResemble, content[2:4]) - }) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPost, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm := request.URL.Query() + qparm.Add("mount", "a") + qparm.Add("mount", "abc") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - Convey("Negative cases", func() { - resp, err = resty.R().SetHeader("Range", "=0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + rthdlr.CreateBlobUpload(response, request) - resp, err = resty.R().SetHeader("Range", "=a").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - resp, err = resty.R().SetHeader("Range", "").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPost, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + qparm = request.URL.Query() + qparm.Add("mount", "a") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - resp, err = resty.R().SetHeader("Range", "=").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + rthdlr.CreateBlobUpload(response, request) - resp, err = resty.R().SetHeader("Range", "byte=").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusAccepted) + }) - resp, err = resty.R().SetHeader("Range", "bytes=").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + Convey("Get blob upload", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - resp, err = resty.R().SetHeader("Range", "byte=-0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + rthdlr.GetBlobUpload(response, request) - resp, err = resty.R().SetHeader("Range", "byte=0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - resp, err = resty.R().SetHeader("Range", "octet=-0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - resp, err = resty.R().SetHeader("Range", "bytes=-0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + rthdlr.GetBlobUpload(response, request) - resp, err = resty.R().SetHeader("Range", "bytes=1-0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) - resp, err = resty.R().SetHeader("Range", "bytes=-1-0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + Convey("Patch blob upload", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPatch, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - resp, err = resty.R().SetHeader("Range", "bytes=-1--0").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + rthdlr.PatchBlobUpload(response, request) - resp, err = resty.R().SetHeader("Range", "bytes=1--2").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - resp, err = resty.R().SetHeader("Range", "bytes=0-a").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - resp, err = resty.R().SetHeader("Range", "bytes=a-10").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + rthdlr.PatchBlobUpload(response, request) - resp, err = resty.R().SetHeader("Range", "bytes=a-b").Get(blobLoc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) }) - }) -} -func TestInjectInterruptedImageManifest(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port + Convey("Update blob upload", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - dir := t.TempDir() - ctlr := makeController(conf, dir) + rthdlr.UpdateBlobUpload(response, request) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - rthdlr := api.NewRouteHandler(ctlr) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - Convey("Upload a blob & a config blob; Create an image manifest", func() { - // create a blob/layer - resp, err := resty.R().Post(baseURL + "/v2/repotest/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := test.Location(baseURL, resp) - So(loc, ShouldNotBeEmpty) + rthdlr.UpdateBlobUpload(response, request) - // since we are not specifying any prefix i.e provided in config while starting server, - // so it should store repotest to global root dir - _, err = os.Stat(path.Join(dir, "repotest")) - So(err, ShouldBeNil) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - resp, err = resty.R().Get(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) - content := []byte("this is a dummy blob") - digest := godigest.FromBytes(content) - So(digest, ShouldNotBeNil) - // monolithic blob upload: success - resp, err = resty.R().SetQueryParam("digest", digest.String()). - SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - blobLoc := resp.Header().Get("Location") - So(blobLoc, ShouldNotBeEmpty) - So(resp.Header().Get("Content-Length"), ShouldEqual, "0") - So(resp.Header().Get(constants.DistContentDigestKey), ShouldNotBeEmpty) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "session_id": "bar"}) + response = httptest.NewRecorder() - // upload image config blob - resp, err = resty.R().Post(baseURL + "/v2/repotest/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc = test.Location(baseURL, resp) - cblob, cdigest := GetRandomImageConfig() + rthdlr.UpdateBlobUpload(response, request) - resp, err = resty.R(). - SetContentLength(true). - SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", cdigest.String()). - SetBody(cblob). - Put(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) - // create a manifest - manifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: "application/vnd.oci.image.config.v1+json", - Digest: cdigest, - Size: int64(len(cblob)), - }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: digest, - Size: int64(len(content)), - }, - }, - } - manifest.SchemaVersion = 2 - content, err = json.Marshal(manifest) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo", "session_id": "bar"}) + qparm := request.URL.Query() + qparm.Add("digest", "a") + qparm.Add("digest", "abc") + request.URL.RawQuery = qparm.Encode() + response = httptest.NewRecorder() - // Testing router path: @Router /v2/{name}/manifests/{reference} [put] - Convey("Uploading an image manifest blob (when injected simulates an interrupted image manifest upload)", func() { - injected := inject.InjectFailure(0) + rthdlr.UpdateBlobUpload(response, request) - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(content)) - request = mux.SetURLVars(request, map[string]string{"name": "repotest", "reference": "1.0"}) - request.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - response := httptest.NewRecorder() + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) + }) - rthdlr.UpdateManifest(response, request) + Convey("Delete blob upload", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - resp := response.Result() - defer resp.Body.Close() + rthdlr.DeleteBlobUpload(response, request) - So(resp, ShouldNotBeNil) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - if injected { - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) - } else { - So(resp.StatusCode, ShouldEqual, http.StatusCreated) - } - }) - }) - }) -} + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() -func TestInjectTooManyOpenFiles(t *testing.T) { - Convey("Make a new controller", t, func() { - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port + rthdlr.DeleteBlobUpload(response, request) - dir := t.TempDir() - ctlr := makeController(conf, dir) - conf.Storage.RemoteCache = false + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + Convey("Get referrers", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{}) + response := httptest.NewRecorder() - rthdlr := api.NewRouteHandler(ctlr) + rthdlr.GetOrasReferrers(response, request) - // create a blob/layer - resp, err := resty.R().Post(baseURL + "/v2/repotest/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := test.Location(baseURL, resp) - So(loc, ShouldNotBeEmpty) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) - // since we are not specifying any prefix i.e provided in config while starting server, - // so it should store repotest to global root dir - _, err = os.Stat(path.Join(dir, "repotest")) - So(err, ShouldBeNil) + request, _ = http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "foo"}) + response = httptest.NewRecorder() - resp, err = resty.R().Get(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) - content := []byte("this is a dummy blob") - digest := godigest.FromBytes(content) - So(digest, ShouldNotBeNil) + rthdlr.GetOrasReferrers(response, request) - // monolithic blob upload - injected := inject.InjectFailure(2) - if injected { - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, loc, bytes.NewReader(content)) - tokens := strings.Split(loc, "/") - request = mux.SetURLVars(request, map[string]string{"name": "repotest", "session_id": tokens[len(tokens)-1]}) - q := request.URL.Query() - q.Add("digest", digest.String()) - request.URL.RawQuery = q.Encode() - request.Header.Set("Content-Type", "application/octet-stream") - request.Header.Set("Content-Length", fmt.Sprintf("%d", len(content))) - response := httptest.NewRecorder() + resp = response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) + }) + }) +} - rthdlr.UpdateBlobUpload(response, request) +func TestListingTags(t *testing.T) { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port - resp := response.Result() - defer resp.Body.Close() + ctlr := makeController(conf, t.TempDir()) + ctlr.Config.Storage.Commit = true - So(resp, ShouldNotBeNil) - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) - } else { - resp, err = resty.R().SetQueryParam("digest", digest.String()). - SetHeader("Content-Type", "application/octet-stream"). - SetBody(content).Put(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) - blobLoc := resp.Header().Get("Location") - So(blobLoc, ShouldNotBeEmpty) - So(resp.Header().Get("Content-Length"), ShouldEqual, "0") - So(resp.Header().Get(constants.DistContentDigestKey), ShouldNotBeEmpty) - } + defer cm.StopServer() - // upload image config blob - resp, err = resty.R().Post(baseURL + "/v2/repotest/blobs/uploads/") - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc = test.Location(baseURL, resp) - cblob, cdigest := GetRandomImageConfig() + rthdlr := api.NewRouteHandler(ctlr) - resp, err = resty.R(). - SetContentLength(true). - SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", cdigest.String()). - SetBody(cblob). - Put(loc) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + img := CreateRandomImage() + sigTag := fmt.Sprintf("sha256-%s.sig", img.Digest().Encoded()) - // create a manifest - manifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: "application/vnd.oci.image.config.v1+json", - Digest: cdigest, - Size: int64(len(cblob)), + repoName := "test-tags" + tagsList := []string{ + "1", "2", "1.0.0", "new", "2.0.0", sigTag, + "2-test", "New", "2.0.0-test", "latest", + } + + for _, tag := range tagsList { + err := UploadImage(img, baseURL, repoName, tag) + if err != nil { + panic(err) + } + } + + // Note empty strings signify the query parameter is not set + // There are separate tests for passing the empty string as query parameter + testCases := []struct { + testCaseName string + pageSize string + last string + expectedTags []string + }{ + { + testCaseName: "Test tag soting order with no parameters", + pageSize: "", + last: "", + expectedTags: []string{ + "1", "1.0.0", "2", "2-test", "2.0.0", "2.0.0-test", + "New", "latest", "new", sigTag, }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: digest, - Size: int64(len(content)), - }, + }, + { + testCaseName: "Test with the parameter 'n' lower than total number of results", + pageSize: "5", + last: "", + expectedTags: []string{ + "1", "1.0.0", "2", "2-test", "2.0.0", }, - } - manifest.SchemaVersion = 2 - content, err = json.Marshal(manifest) - So(err, ShouldBeNil) - digest = godigest.FromBytes(content) - So(digest, ShouldNotBeNil) + }, + { + testCaseName: "Test with the parameter 'n' larger than total number of results", + pageSize: "50", + last: "", + expectedTags: []string{ + "1", "1.0.0", "2", "2-test", "2.0.0", "2.0.0-test", + "New", "latest", "new", sigTag, + }, + }, + { + testCaseName: "Test the parameter 'n' is 0", + pageSize: "0", + last: "", + expectedTags: []string{}, + }, + { + testCaseName: "Test the parameters 'n' and 'last'", + pageSize: "5", + last: "2-test", + expectedTags: []string{"2.0.0", "2.0.0-test", "New", "latest", "new"}, + }, + { + testCaseName: "Test the parameters 'n' and 'last' with `n` exceeding total number of results", + pageSize: "5", + last: "latest", + expectedTags: []string{"new", sigTag}, + }, + { + testCaseName: "Test the parameter 'n' and 'last' being second to last tag as value", + pageSize: "2", + last: "new", + expectedTags: []string{sigTag}, + }, + { + testCaseName: "Test the parameter 'last' without parameter 'n'", + pageSize: "", + last: "2", + expectedTags: []string{ + "2-test", "2.0.0", "2.0.0-test", + "New", "latest", "new", sigTag, + }, + }, + { + testCaseName: "Test the parameter 'last' with the final tag as value", + pageSize: "", + last: sigTag, + expectedTags: []string{}, + }, + { + testCaseName: "Test the parameter 'last' with the second to last tag as value", + pageSize: "", + last: "new", + expectedTags: []string{sigTag}, + }, + } - // Testing router path: @Router /v2/{name}/manifests/{reference} [put] - //nolint:lll // gofumpt conflicts with lll - Convey("Uploading an image manifest blob (when injected simulates that PutImageManifest failed due to 'too many open files' error)", func() { - injected := inject.InjectFailure(2) + for _, testCase := range testCases { + Convey(testCase.testCaseName, t, func() { + t.Log("Running " + testCase.testCaseName) - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(content)) - request = mux.SetURLVars(request, map[string]string{"name": "repotest", "reference": "1.0"}) - request.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - response := httptest.NewRecorder() + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": repoName}) - rthdlr.UpdateManifest(response, request) + if testCase.pageSize != "" || testCase.last != "" { + qparm := request.URL.Query() - resp := response.Result() - So(resp, ShouldNotBeNil) - defer resp.Body.Close() + if testCase.pageSize != "" { + qparm.Add("n", testCase.pageSize) + } - if injected { - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) - } else { - So(resp.StatusCode, ShouldEqual, http.StatusCreated) + if testCase.last != "" { + qparm.Add("last", testCase.last) + } + + request.URL.RawQuery = qparm.Encode() } - }) - Convey("when injected simulates a `too many open files` error inside PutImageManifest method of img store", func() { - injected := inject.InjectFailure(2) - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(content)) - request = mux.SetURLVars(request, map[string]string{"name": "repotest", "reference": "1.0"}) - request.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") response := httptest.NewRecorder() - rthdlr.UpdateManifest(response, request) + rthdlr.ListTags(response, request) resp := response.Result() defer resp.Body.Close() - So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) - if injected { - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + var tags common.ImageTags + err := json.NewDecoder(resp.Body).Decode(&tags) + So(err, ShouldBeNil) + So(tags.Tags, ShouldEqual, testCase.expectedTags) + + alltags := tagsList + sort.Strings(alltags) + + actualLinkValue := resp.Header.Get("Link") + if testCase.pageSize == "0" || testCase.pageSize == "" { //nolint:gocritic + So(actualLinkValue, ShouldEqual, "") + } else if testCase.expectedTags[len(testCase.expectedTags)-1] == alltags[len(alltags)-1] { + So(actualLinkValue, ShouldEqual, "") } else { - So(resp.StatusCode, ShouldEqual, http.StatusCreated) + expectedLinkValue := fmt.Sprintf("/v2/%s/tags/list?n=%s&last=%s; rel=\"next\"", + repoName, testCase.pageSize, tags.Tags[len(tags.Tags)-1], + ) + So(actualLinkValue, ShouldEqual, expectedLinkValue) } + + t.Log("Finished " + testCase.testCaseName) }) - Convey("code coverage: error inside PutImageManifest method of img store (unable to marshal JSON)", func() { - injected := inject.InjectFailure(1) + } +} - request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(content)) - request = mux.SetURLVars(request, map[string]string{"name": "repotest", "reference": "1.0"}) - request.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - response := httptest.NewRecorder() +func TestStorageCommit(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port - rthdlr.UpdateManifest(response, request) + dir := t.TempDir() + ctlr := makeController(conf, dir) + ctlr.Config.Storage.Commit = true - resp := response.Result() - defer resp.Body.Close() + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - So(resp, ShouldNotBeNil) + Convey("Manifests", func() { + _, _ = Print("\nManifests") + + cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck + So(err, ShouldBeNil) + + content := []byte("this is a blob5") + digest := godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + // check a non-existent manifest + resp, err := resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(content).Head(baseURL + "/v2/unknown/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + repoName := "repo7" + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, "test:1.0") + So(err, ShouldBeNil) - if injected { - So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) - } else { - So(resp.StatusCode, ShouldEqual, http.StatusCreated) - } - }) + _, err = os.Stat(path.Join(dir, "repo7")) + So(err, ShouldBeNil) - Convey("when index.json is not in json format", func() { + content, err = json.Marshal(manifest) + So(err, ShouldBeNil) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(content).Put(baseURL + "/v2/repotest/manifests/v1.0") + SetBody(content).Put(baseURL + "/v2/repo7/manifests/test:1.0") So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) digestHdr := resp.Header().Get(constants.DistContentDigestKey) So(digestHdr, ShouldNotBeEmpty) So(digestHdr, ShouldEqual, digest.String()) - indexFile := path.Join(dir, "repotest", "index.json") - _, err = os.Stat(indexFile) + resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(content).Put(baseURL + "/v2/repo7/manifests/test:1.0.1") So(err, ShouldBeNil) - indexContent := []byte(`not a JSON content`) - err = os.WriteFile(indexFile, indexContent, 0o600) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) + + content, err = json.Marshal(manifest) So(err, ShouldBeNil) - resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(content).Put(baseURL + "/v2/repotest/manifests/v1.1") + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, "test:1.0.1") So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) - }) - }) -} -func TestGCSignaturesAndUntaggedManifestsWithMetaDB(t *testing.T) { - Convey("Make controller", t, func() { - Convey("Garbage collect signatures without subject and manifests without tags", func(c C) { - repoName := "testrepo" //nolint:goconst - tag := "0.0.1" + cfg, layers, manifest, err = deprecated.GetImageComponents(1) //nolint:staticcheck + So(err, ShouldBeNil) - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, "test:2.0") + So(err, ShouldBeNil) - value := true - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &value}, - } + resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(content).Put(baseURL + "/v2/repo7/manifests/test:2.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) - // added search extensions so that metaDB is initialized and its tested in GC logic - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - } + // check/get by tag + resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) + resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldNotBeEmpty) + // check/get by reference + resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) + resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldNotBeEmpty) - ctlr := makeController(conf, t.TempDir()) + // delete manifest by tag should pass + resp, err = resty.R().Delete(baseURL + "/v2/repo7/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + // delete manifest by digest (1.0 deleted but 1.0.1 has same reference) + resp, err = resty.R().Delete(baseURL + "/v2/repo7/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + // delete manifest by digest + resp, err = resty.R().Delete(baseURL + "/v2/repo7/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + // delete again should fail + resp, err = resty.R().Delete(baseURL + "/v2/repo7/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - dir := t.TempDir() - ctlr.Config.Storage.RootDirectory = dir - ctlr.Config.Storage.GC = true - ctlr.Config.Storage.GCDelay = 1 * time.Millisecond - ctlr.Config.Storage.UntaggedImageRetentionDelay = 1 * time.Millisecond + // check/get by tag + resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.Body(), ShouldNotBeEmpty) + resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/test:2.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/test:2.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.Body(), ShouldNotBeEmpty) + // check/get by reference + resp, err = resty.R().Head(baseURL + "/v2/repo7/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + resp, err = resty.R().Get(baseURL + "/v2/repo7/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.Body(), ShouldNotBeEmpty) + }) + }) +} - ctlr.Config.Storage.Dedupe = false +func TestManifestImageIndex(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port - cm := test.NewControllerManager(ctlr) - cm.StartServer() - cm.WaitServerToBeReady(port) - defer cm.StopServer() + dir := t.TempDir() + ctlr := makeController(conf, dir) - img := CreateDefaultImage() + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - err := UploadImage(img, baseURL, repoName, tag) - So(err, ShouldBeNil) + rthdlr := api.NewRouteHandler(ctlr) - gc := gc.NewGarbageCollect(ctlr.StoreController.DefaultStore, ctlr.MetaDB, - gc.Options{ - Referrers: ctlr.Config.Storage.GCReferrers, - Delay: ctlr.Config.Storage.GCDelay, - RetentionDelay: ctlr.Config.Storage.UntaggedImageRetentionDelay, - }, - ctlr.Log) + cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck + So(err, ShouldBeNil) - resp, err := resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, tag)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - digest := godigest.FromBytes(resp.Body()) - So(digest, ShouldNotBeEmpty) + content := []byte("this is a blob1") + digest := godigest.FromBytes(content) + So(digest, ShouldNotBeNil) - cwd, err := os.Getwd() - So(err, ShouldBeNil) - defer func() { _ = os.Chdir(cwd) }() - tdir := t.TempDir() - _ = os.Chdir(tdir) + // check a non-existent manifest + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Head(baseURL + "/v2/unknown/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - // generate a keypair - os.Setenv("COSIGN_PASSWORD", "") - err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) - So(err, ShouldBeNil) + repoName := "index" + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, "test:1.0") + So(err, ShouldBeNil) - image := fmt.Sprintf("localhost:%s/%s@%s", port, repoName, digest.String()) + _, err = os.Stat(path.Join(dir, "index")) + So(err, ShouldBeNil) - annotations := []string{fmt.Sprintf("tag=%s", tag)} + content, err = json.Marshal(manifest) + So(err, ShouldBeNil) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + m1content := content + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Put(baseURL + "/v2/index/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr := resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) - // sign the image - err = sign.SignCmd(&options.RootOptions{Verbose: true, Timeout: 1 * time.Minute}, - options.KeyOpts{KeyRef: path.Join(tdir, "cosign.key"), PassFunc: generate.GetPass}, - options.SignOptions{ - Registry: options.RegistryOptions{AllowInsecure: true}, - AnnotationOptions: options.AnnotationOptions{Annotations: annotations}, - Upload: true, - }, - []string{image}) + // create another manifest but upload using its sha256 reference - So(err, ShouldBeNil) + // upload image config blob + resp, err = resty.R().Post(baseURL + "/v2/index/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - signature.NotationPathLock.Lock() - defer signature.NotationPathLock.Unlock() + img := CreateRandomImage() - signature.LoadNotationPath(tdir) + err = UploadImage(img, baseURL, repoName, img.DigestStr()) + So(err, ShouldBeNil) - // generate a keypair - err = signature.GenerateNotationCerts(tdir, "good") - So(err, ShouldBeNil) + content = img.ManifestDescriptor.Data + digest = img.ManifestDescriptor.Digest - // sign the image - err = signature.SignWithNotation("good", image, tdir) - So(err, ShouldBeNil) + m2dgst := digest + m2size := len(content) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) - // get cosign signature manifest - cosignTag := strings.Replace(digest.String(), ":", "-", 1) + "." + remote.SignatureTagSuffix + Convey("Image index", func() { + img := CreateRandomImage() - resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, cosignTag)) + err = UploadImage(img, baseURL, repoName, img.DigestStr()) So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - cosignDigest := resp.Header().Get(constants.DistContentDigestKey) - So(cosignDigest, ShouldNotBeEmpty) + content := img.ManifestDescriptor.Data + digest = img.ManifestDescriptor.Digest - // get notation signature manifest - resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( - fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest.String())) So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr := resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) var index ispec.Index + index.SchemaVersion = 2 + index.Manifests = []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageManifest, + Digest: digest, + Size: int64(len(content)), + }, + { + MediaType: ispec.MediaTypeImageManifest, + Digest: m2dgst, + Size: int64(m2size), + }, + } - err = json.Unmarshal(resp.Body(), &index) + content, err = json.Marshal(index) So(err, ShouldBeNil) - So(len(index.Manifests), ShouldEqual, 1) - - // shouldn't do anything - err = gc.CleanRepo(repoName) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + index1dgst := digest + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(content).Put(baseURL + "/v2/index/manifests/test:index1") So(err, ShouldBeNil) - - // make sure both signatures are stored in repodb - repoMeta, err := ctlr.MetaDB.GetRepoMeta(repoName) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index1") So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldNotBeEmpty) + So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - sigMeta := repoMeta.Signatures[digest.String()] - So(len(sigMeta[storage.CosignType]), ShouldEqual, 1) - So(len(sigMeta[storage.NotationType]), ShouldEqual, 1) - So(sigMeta[storage.CosignType][0].SignatureManifestDigest, ShouldEqual, cosignDigest) - So(sigMeta[storage.NotationType][0].SignatureManifestDigest, ShouldEqual, index.Manifests[0].Digest.String()) - - Convey("Trigger gcNotationSignatures() error", func() { - var refs ispec.Index - err = json.Unmarshal(resp.Body(), &refs) + img = CreateRandomImage() - err := os.Chmod(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded()), 0o000) - So(err, ShouldBeNil) + err = UploadImage(img, baseURL, repoName, img.DigestStr()) + So(err, ShouldBeNil) - // trigger gc - img := CreateRandomImage() + content = img.ManifestDescriptor.Data + digest = img.ManifestDescriptor.Digest - err = UploadImage(img, baseURL, repoName, img.DigestStr()) - So(err, ShouldBeNil) + m4dgst := digest + m4size := len(content) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) - err = gc.CleanRepo(repoName) - So(err, ShouldNotBeNil) + index.SchemaVersion = 2 + index.Manifests = []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageManifest, + Digest: digest, + Size: int64(len(content)), + }, + { + MediaType: ispec.MediaTypeImageManifest, + Digest: m2dgst, + Size: int64(m2size), + }, + } - err = os.Chmod(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded()), 0o755) - So(err, ShouldBeNil) + content, err = json.Marshal(index) + So(err, ShouldBeNil) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(content).Put(baseURL + "/v2/index/manifests/test:index2") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index2") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldNotBeEmpty) + So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - content, err := os.ReadFile(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded())) - So(err, ShouldBeNil) - err = os.WriteFile(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded()), []byte("corrupt"), 0o600) //nolint:lll - So(err, ShouldBeNil) + Convey("List tags", func() { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) + request = mux.SetURLVars(request, map[string]string{"name": "index"}) + response := httptest.NewRecorder() - err = UploadImage(img, baseURL, repoName, tag) - So(err, ShouldBeNil) + rthdlr.ListTags(response, request) - err = gc.CleanRepo(repoName) - So(err, ShouldNotBeNil) + resp := response.Result() + defer resp.Body.Close() + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) - err = os.WriteFile(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded()), content, 0o600) + var tags common.ImageTags + err = json.NewDecoder(resp.Body).Decode(&tags) So(err, ShouldBeNil) + So(len(tags.Tags), ShouldEqual, 3) + So(tags.Tags, ShouldContain, "test:1.0") + So(tags.Tags, ShouldContain, "test:index1") + So(tags.Tags, ShouldContain, "test:index2") }) - Convey("Overwrite original image, signatures should be garbage-collected", func() { - // push an image without tag - cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck - So(err, ShouldBeNil) - - manifestBuf, err := json.Marshal(manifest) - So(err, ShouldBeNil) - untaggedManifestDigest := godigest.FromBytes(manifestBuf) + Convey("Another index with same manifest", func() { + var index ispec.Index + index.SchemaVersion = 2 + index.Manifests = []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageManifest, + Digest: m4dgst, + Size: int64(m4size), + }, + } - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, untaggedManifestDigest.String()) + content, err = json.Marshal(index) So(err, ShouldBeNil) - - // make sure repoDB reference was added - repoMeta, err := ctlr.MetaDB.GetRepoMeta(repoName) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(content).Put(baseURL + "/v2/index/manifests/test:index3") So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) + }) - _, ok := repoMeta.Referrers[untaggedManifestDigest.String()] - So(ok, ShouldBeTrue) - _, ok = repoMeta.Signatures[untaggedManifestDigest.String()] - So(ok, ShouldBeTrue) - _, ok = repoMeta.Statistics[untaggedManifestDigest.String()] - So(ok, ShouldBeTrue) - - // overwrite image so that signatures will get invalidated and gc'ed - cfg, layers, manifest, err = deprecated.GetImageComponents(3) //nolint:staticcheck - So(err, ShouldBeNil) + Convey("Another index using digest with same manifest", func() { + var index ispec.Index + index.SchemaVersion = 2 + index.Manifests = []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageManifest, + Digest: m4dgst, + Size: int64(m4size), + }, + } - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, tag) + content, err = json.Marshal(index) So(err, ShouldBeNil) - - manifestBuf, err = json.Marshal(manifest) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest)) So(err, ShouldBeNil) - newManifestDigest := godigest.FromBytes(manifestBuf) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) + }) - err = gc.CleanRepo(repoName) + Convey("Deleting manifest contained by a multiarch image should not be allowed", func() { + resp, err = resty.R().Delete(baseURL + fmt.Sprintf("/v2/index/manifests/%s", m2dgst.String())) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusMethodNotAllowed) + }) - // make sure both signatures are removed from repodb and repo reference for untagged is removed - repoMeta, err = ctlr.MetaDB.GetRepoMeta(repoName) + Convey("Deleting an image index", func() { + // delete manifest by tag should pass + resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/test:index3") So(err, ShouldBeNil) - - sigMeta := repoMeta.Signatures[digest.String()] - So(len(sigMeta[storage.CosignType]), ShouldEqual, 0) - So(len(sigMeta[storage.NotationType]), ShouldEqual, 0) - - _, ok = repoMeta.Referrers[untaggedManifestDigest.String()] - So(ok, ShouldBeFalse) - _, ok = repoMeta.Signatures[untaggedManifestDigest.String()] - So(ok, ShouldBeFalse) - _, ok = repoMeta.Statistics[untaggedManifestDigest.String()] - So(ok, ShouldBeFalse) - - // both signatures should be gc'ed - resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, cosignTag)) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index3") So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - - resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( - fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) + So(resp.Body(), ShouldNotBeEmpty) + resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/test:index1") + So(err, ShouldBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index1") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.Body(), ShouldNotBeEmpty) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index2") So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldNotBeEmpty) + So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) + }) - err = json.Unmarshal(resp.Body(), &index) + Convey("Deleting an image index by digest", func() { + // delete manifest by tag should pass + resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/test:index3") So(err, ShouldBeNil) - So(len(index.Manifests), ShouldEqual, 0) - - resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( - fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, newManifestDigest.String())) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index3") So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - err = json.Unmarshal(resp.Body(), &index) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.Body(), ShouldNotBeEmpty) + resp, err = resty.R().Delete(baseURL + fmt.Sprintf("/v2/index/manifests/%s", index1dgst)) So(err, ShouldBeNil) - So(len(index.Manifests), ShouldEqual, 0) - - // untagged image should also be gc'ed - resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, untaggedManifestDigest)) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index1") So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.Body(), ShouldNotBeEmpty) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index2") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldNotBeEmpty) + So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) }) - }) - - Convey("Do not gc manifests which are part of a multiarch image", func(c C) { - repoName := "testrepo" //nolint:goconst - tag := "0.0.1" - - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf := config.New() - conf.HTTP.Port = port - - ctlr := makeController(conf, t.TempDir()) - - dir := t.TempDir() - ctlr.Config.Storage.RootDirectory = dir - ctlr.Config.Storage.GC = true - ctlr.Config.Storage.GCDelay = 1 * time.Second - ctlr.Config.Storage.UntaggedImageRetentionDelay = 1 * time.Second - - err := WriteImageToFileSystem(CreateDefaultImage(), repoName, tag, - ociutils.GetDefaultStoreController(dir, ctlr.Log)) - So(err, ShouldBeNil) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - gc := gc.NewGarbageCollect(ctlr.StoreController.DefaultStore, ctlr.MetaDB, - gc.Options{ - Referrers: ctlr.Config.Storage.GCReferrers, - Delay: ctlr.Config.Storage.GCDelay, - RetentionDelay: ctlr.Config.Storage.UntaggedImageRetentionDelay, - }, ctlr.Log) - - resp, err := resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, tag)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - digest := godigest.FromBytes(resp.Body()) - So(digest, ShouldNotBeEmpty) - - // push an image index and make sure manifests contained by it are not gc'ed - // create an image index on upstream - var index ispec.Index - index.SchemaVersion = 2 - index.MediaType = ispec.MediaTypeImageIndex - // upload multiple manifests - for i := 0; i < 4; i++ { - config, layers, manifest, err := deprecated.GetImageComponents(1000 + i) //nolint:staticcheck - So(err, ShouldBeNil) + Convey("Update an index tag with different manifest", func() { + img := CreateRandomImage() - manifestContent, err := json.Marshal(manifest) + err = UploadImage(img, baseURL, repoName, img.DigestStr()) So(err, ShouldBeNil) - manifestDigest := godigest.FromBytes(manifestContent) + content = img.ManifestDescriptor.Data + digest = img.ManifestDescriptor.Digest - err = UploadImage( - Image{ - Manifest: manifest, - Config: config, - Layers: layers, - }, baseURL, repoName, manifestDigest.String()) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/index/manifests/%s", digest)) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) - index.Manifests = append(index.Manifests, ispec.Descriptor{ - Digest: manifestDigest, - MediaType: ispec.MediaTypeImageManifest, - Size: int64(len(manifestContent)), - }) - } - - content, err := json.Marshal(index) - So(err, ShouldBeNil) - indexDigest := godigest.FromBytes(content) - So(indexDigest, ShouldNotBeNil) - - time.Sleep(1 * time.Second) - // upload image index - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - SetBody(content).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/latest", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - err = gc.CleanRepo(repoName) - So(err, ShouldBeNil) - - resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). - Get(baseURL + fmt.Sprintf("/v2/%s/manifests/latest", repoName)) - So(err, ShouldBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldNotBeEmpty) - So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) + index.SchemaVersion = 2 + index.Manifests = []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageManifest, + Digest: digest, + Size: int64(len(content)), + }, + } - // make sure manifests which are part of image index are not gc'ed - for _, manifest := range index.Manifests { - resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, manifest.Digest.String())) + content, err = json.Marshal(index) + So(err, ShouldBeNil) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(content).Put(baseURL + "/v2/index/manifests/test:index1") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr = resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index1") So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) - } - }) - }) -} - -func TestPeriodicGC(t *testing.T) { - Convey("Periodic gc enabled for default store", t, func() { - repoName := "testrepo" //nolint:goconst - - port := test.GetFreePort() - conf := config.New() - conf.HTTP.Port = port - conf.Storage.RemoteCache = false + So(resp.Body(), ShouldNotBeEmpty) + So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - logFile, err := os.CreateTemp("", "zot-log*.txt") - So(err, ShouldBeNil) - conf.Log.Output = logFile.Name() - defer os.Remove(logFile.Name()) // clean up + // delete manifest by tag should pass + resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/test:index1") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index1") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.Body(), ShouldNotBeEmpty) + }) - ctlr := api.NewController(conf) - dir := t.TempDir() - ctlr.Config.Storage.RootDirectory = dir - ctlr.Config.Storage.Dedupe = false - ctlr.Config.Storage.GC = true - ctlr.Config.Storage.GCInterval = 1 * time.Hour - ctlr.Config.Storage.GCDelay = 1 * time.Second + Convey("Negative test cases", func() { + Convey("Delete index", func() { + err = os.Remove(path.Join(dir, "index", "blobs", index1dgst.Algorithm().String(), index1dgst.Encoded())) + So(err, ShouldBeNil) + resp, err = resty.R().Delete(baseURL + fmt.Sprintf("/v2/index/manifests/%s", index1dgst)) + So(err, ShouldBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index1") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + So(resp.Body(), ShouldNotBeEmpty) + }) - err = WriteImageToFileSystem(CreateDefaultImage(), repoName, "0.0.1", - ociutils.GetDefaultStoreController(dir, ctlr.Log)) - So(err, ShouldBeNil) + Convey("Corrupt index", func() { + err = os.WriteFile(path.Join(dir, "index", "blobs", index1dgst.Algorithm().String(), index1dgst.Encoded()), + []byte("deadbeef"), storageConstants.DefaultFilePerms) + So(err, ShouldBeNil) + resp, err = resty.R().Delete(baseURL + fmt.Sprintf("/v2/index/manifests/%s", index1dgst)) + So(err, ShouldBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + "/v2/index/manifests/test:index1") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + So(resp.Body(), ShouldBeEmpty) + }) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + Convey("Change media-type", func() { + // previously a manifest, try writing an image index + var index ispec.Index + index.SchemaVersion = 2 + index.Manifests = []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageIndex, + Digest: m4dgst, + Size: int64(m4size), + }, + } - time.Sleep(5000 * time.Millisecond) + content, err = json.Marshal(index) + So(err, ShouldBeNil) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(content).Put(baseURL + "/v2/index/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - data, err := os.ReadFile(logFile.Name()) - So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, - "\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":3600000000000") - So(string(data), ShouldContainSubstring, - fmt.Sprintf("executing GC of orphaned blobs for %s", path.Join(ctlr.StoreController.DefaultStore.RootDir(), repoName))) //nolint:lll - So(string(data), ShouldContainSubstring, - fmt.Sprintf("GC successfully completed for %s", path.Join(ctlr.StoreController.DefaultStore.RootDir(), repoName))) //nolint:lll + // previously an image index, try writing a manifest + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(m1content).Put(baseURL + "/v2/index/manifests/test:index1") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + }) + }) + }) }) +} - Convey("Periodic GC enabled for substore", t, func() { +func TestManifestCollision(t *testing.T) { + Convey("Make a new controller", t, func() { port := test.GetFreePort() + baseURL := test.GetBaseURL(port) conf := config.New() conf.HTTP.Port = port - logFile, err := os.CreateTemp("", "zot-log*.txt") - So(err, ShouldBeNil) - conf.Log.Output = logFile.Name() - defer os.Remove(logFile.Name()) // clean up - dir := t.TempDir() ctlr := makeController(conf, dir) - subDir := t.TempDir() - - subPaths := make(map[string]config.StorageConfig) - subPaths["/a"] = config.StorageConfig{ - RootDirectory: subDir, GC: true, GCDelay: 1 * time.Second, - UntaggedImageRetentionDelay: 1 * time.Second, GCInterval: 24 * time.Hour, RemoteCache: false, Dedupe: false, - } //nolint:lll // gofumpt conflicts with lll - ctlr.Config.Storage.Dedupe = false - ctlr.Config.Storage.SubPaths = subPaths + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + AuthorizationAllRepos: config.PolicyGroup{ + AnonymousPolicy: []string{ + constants.ReadPermission, + constants.CreatePermission, + constants.DeletePermission, + constants.DetectManifestCollisionPermission, + }, + }, + }, + } cm := test.NewControllerManager(ctlr) cm.StartAndWait(port) defer cm.StopServer() - data, err := os.ReadFile(logFile.Name()) + cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck So(err, ShouldBeNil) - // periodic GC is enabled by default for default store with a default interval - So(string(data), ShouldContainSubstring, - "\"GCDelay\":3600000000000,\"GCInterval\":3600000000000,\"") - // periodic GC is enabled for sub store - So(string(data), ShouldContainSubstring, - fmt.Sprintf("\"SubPaths\":{\"/a\":{\"RootDirectory\":\"%s\",\"Dedupe\":false,\"RemoteCache\":false,\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":86400000000000", subDir)) //nolint:lll // gofumpt conflicts with lll - }) - - Convey("Periodic gc error", t, func() { - repoName := "testrepo" //nolint:goconst - port := test.GetFreePort() - conf := config.New() - conf.HTTP.Port = port - conf.Storage.RemoteCache = false + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, "index", "test:1.0") + So(err, ShouldBeNil) - logFile, err := os.CreateTemp("", "zot-log*.txt") + _, err = os.Stat(path.Join(dir, "index")) So(err, ShouldBeNil) - conf.Log.Output = logFile.Name() - defer os.Remove(logFile.Name()) // clean up - ctlr := api.NewController(conf) - dir := t.TempDir() - ctlr.Config.Storage.RootDirectory = dir - ctlr.Config.Storage.Dedupe = false + content := []byte("this is a blob1") + digest := godigest.FromBytes(content) + So(digest, ShouldNotBeNil) - ctlr.Config.Storage.GC = true - ctlr.Config.Storage.GCInterval = 1 * time.Hour - ctlr.Config.Storage.GCDelay = 1 * time.Second + // check a non-existent manifest + resp, err := resty.R().SetHeader("Content-Type", ispec.MediaTypeImageManifest). + SetBody(content).Head(baseURL + "/v2/unknown/manifests/test:1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - err = WriteImageToFileSystem(CreateDefaultImage(), repoName, "0.0.1", - ociutils.GetDefaultStoreController(dir, ctlr.Log)) + content, err = json.Marshal(manifest) So(err, ShouldBeNil) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) - So(os.Chmod(dir, 0o000), ShouldBeNil) + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, "index", "test:2.0") + So(err, ShouldBeNil) - defer func() { - So(os.Chmod(dir, 0o755), ShouldBeNil) - }() + // Deletion should fail if using digest + resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusConflict) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + // remove detectManifestCollision action from ** (all repos) + repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] + repoPolicy.AnonymousPolicy = []string{"read", "delete"} + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy - time.Sleep(5000 * time.Millisecond) + resp, err = resty.R().Delete(baseURL + "/v2/index/manifests/" + digest.String()) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - data, err := os.ReadFile(logFile.Name()) + resp, err = resty.R().Get(baseURL + "/v2/index/manifests/test:1.0") So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, - "\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":3600000000000") - So(string(data), ShouldContainSubstring, "failure walking storage root-dir") //nolint:lll + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + + resp, err = resty.R().Get(baseURL + "/v2/index/manifests/test:2.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) }) } -func TestSearchRoutes(t *testing.T) { - Convey("Upload image for test", t, func(c C) { +func TestPullRange(t *testing.T) { + Convey("Make a new controller", t, func() { port := test.GetFreePort() baseURL := test.GetBaseURL(port) conf := config.New() conf.HTTP.Port = port - tempDir := t.TempDir() - ctlr := makeController(conf, tempDir) - cm := test.NewControllerManager(ctlr) + dir := t.TempDir() + ctlr := makeController(conf, dir) + cm := test.NewControllerManager(ctlr) cm.StartAndWait(port) defer cm.StopServer() - repoName := "testrepo" //nolint:goconst - inaccessibleRepo := "inaccessible" - - cfg, layers, manifest, err := deprecated.GetImageComponents(10000) //nolint:staticcheck + // create a blob/layer + resp, err := resty.R().Post(baseURL + "/v2/index/blobs/uploads/") So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := test.Location(baseURL, resp) + So(loc, ShouldNotBeEmpty) - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, "latest") - + // since we are not specifying any prefix i.e provided in config while starting server, + // so it should store index1 to global root dir + _, err = os.Stat(path.Join(dir, "index")) So(err, ShouldBeNil) - // data for the inaccessible repo - cfg, layers, manifest, err = deprecated.GetImageComponents(10000) //nolint:staticcheck + resp, err = resty.R().Get(loc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + content := []byte("0123456789") + digest := godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + // monolithic blob upload: success + resp, err = resty.R().SetQueryParam("digest", digest.String()). + SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(loc) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + blobLoc := resp.Header().Get("Location") + So(blobLoc, ShouldNotBeEmpty) + So(resp.Header().Get("Content-Length"), ShouldEqual, "0") + So(resp.Header().Get(constants.DistContentDigestKey), ShouldNotBeEmpty) + blobLoc = baseURL + blobLoc - err = UploadImage( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, inaccessibleRepo, "latest") + Convey("Range is supported using 'bytes'", func() { + resp, err = resty.R().Head(blobLoc) + So(err, ShouldBeNil) + So(resp.Header().Get("Accept-Ranges"), ShouldEqual, "bytes") + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) - So(err, ShouldBeNil) + Convey("Get a range of bytes", func() { + resp, err = resty.R().SetHeader("Range", "bytes=0-").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) + So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content))) + So(resp.Body(), ShouldResemble, content) - Convey("GlobalSearch with authz enabled", func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + resp, err = resty.R().SetHeader("Range", "bytes=0-100").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) + So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content))) + So(resp.Body(), ShouldResemble, content) + + resp, err = resty.R().SetHeader("Range", "bytes=0-10").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) + So(resp.Header().Get("Content-Length"), ShouldEqual, fmt.Sprintf("%d", len(content))) + So(resp.Body(), ShouldResemble, content) + + resp, err = resty.R().SetHeader("Range", "bytes=0-0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) + So(resp.Header().Get("Content-Length"), ShouldEqual, "1") + So(resp.Body(), ShouldResemble, content[0:1]) + + resp, err = resty.R().SetHeader("Range", "bytes=0-1").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) + So(resp.Header().Get("Content-Length"), ShouldEqual, "2") + So(resp.Body(), ShouldResemble, content[0:2]) + + resp, err = resty.R().SetHeader("Range", "bytes=2-3").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusPartialContent) + So(resp.Header().Get("Content-Length"), ShouldEqual, "2") + So(resp.Body(), ShouldResemble, content[2:4]) + }) + + Convey("Negative cases", func() { + resp, err = resty.R().SetHeader("Range", "=0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "=a").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "=").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "byte=").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "bytes=").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "byte=-0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "byte=0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "octet=-0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "bytes=-0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "bytes=1-0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + + resp, err = resty.R().SetHeader("Range", "bytes=-1-0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) - user1 := "test" - password1 := "test" - testString1 := getCredString(user1, password1) - htpasswdPath := test.MakeHtpasswdFileFromString(testString1) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } + resp, err = resty.R().SetHeader("Range", "bytes=-1--0").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) - conf.HTTP.Port = port + resp, err = resty.R().SetHeader("Range", "bytes=1--2").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) - defaultVal := true + resp, err = resty.R().SetHeader("Range", "bytes=0-a").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } + resp, err = resty.R().SetHeader("Range", "bytes=a-10").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - } + resp, err = resty.R().SetHeader("Range", "bytes=a-b").Get(blobLoc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusRequestedRangeNotSatisfiable) + }) + }) +} - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - repoName: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{user1}, - Actions: []string{"read", "create"}, - }, - }, - DefaultPolicy: []string{}, - }, - inaccessibleRepo: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{user1}, - Actions: []string{"create"}, - }, - }, - DefaultPolicy: []string{}, - }, - }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, - }, - } +func TestInjectInterruptedImageManifest(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port - ctlr := makeController(conf, tempDir) + dir := t.TempDir() + ctlr := makeController(conf, dir) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - cfg, layers, manifest, err := deprecated.GetImageComponents(10000) //nolint:staticcheck - So(err, ShouldBeNil) + rthdlr := api.NewRouteHandler(ctlr) - err = UploadImageWithBasicAuth( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, repoName, "latest", - user1, password1) + Convey("Upload a blob & a config blob; Create an image manifest", func() { + // create a blob/layer + resp, err := resty.R().Post(baseURL + "/v2/repotest/blobs/uploads/") So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := test.Location(baseURL, resp) + So(loc, ShouldNotBeEmpty) - // data for the inaccessible repo - cfg, layers, manifest, err = deprecated.GetImageComponents(10000) //nolint:staticcheck + // since we are not specifying any prefix i.e provided in config while starting server, + // so it should store repotest to global root dir + _, err = os.Stat(path.Join(dir, "repotest")) So(err, ShouldBeNil) - err = UploadImageWithBasicAuth( - Image{ - Config: cfg, - Layers: layers, - Manifest: manifest, - }, baseURL, inaccessibleRepo, "latest", - user1, password1) + resp, err = resty.R().Get(loc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + content := []byte("this is a dummy blob") + digest := godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + // monolithic blob upload: success + resp, err = resty.R().SetQueryParam("digest", digest.String()). + SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(loc) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + blobLoc := resp.Header().Get("Location") + So(blobLoc, ShouldNotBeEmpty) + So(resp.Header().Get("Content-Length"), ShouldEqual, "0") + So(resp.Header().Get(constants.DistContentDigestKey), ShouldNotBeEmpty) - query := ` - { - GlobalSearch(query:"testrepo"){ - Repos { - Name - NewestImage { - RepoName - Tag - } - } - } - }` - resp, err := resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.FullSearchPrefix + - "?query=" + url.QueryEscape(query)) + // upload image config blob + resp, err = resty.R().Post(baseURL + "/v2/repotest/blobs/uploads/") So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, 200) - So(string(resp.Body()), ShouldContainSubstring, repoName) - So(string(resp.Body()), ShouldNotContainSubstring, inaccessibleRepo) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + cblob, cdigest := GetRandomImageConfig() - resp, err = resty.R().Get(baseURL + constants.FullSearchPrefix + "?query=" + url.QueryEscape(query)) + resp, err = resty.R(). + SetContentLength(true). + SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Repositories: config.Repositories{ - repoName: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{user1}, - Actions: []string{}, - }, - }, - DefaultPolicy: []string{}, - }, - inaccessibleRepo: config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{}, - Actions: []string{}, - }, - }, - DefaultPolicy: []string{}, - }, + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + + // create a manifest + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest, + Size: int64(len(content)), + }, }, } - - // authenticated, but no access to resource - resp, err = resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.FullSearchPrefix + - "?query=" + url.QueryEscape(query)) + manifest.SchemaVersion = 2 + content, err = json.Marshal(manifest) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(string(resp.Body()), ShouldNotContainSubstring, repoName) - So(string(resp.Body()), ShouldNotContainSubstring, inaccessibleRepo) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) + + // Testing router path: @Router /v2/{name}/manifests/{reference} [put] + Convey("Uploading an image manifest blob (when injected simulates an interrupted image manifest upload)", func() { + injected := inject.InjectFailure(0) + + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(content)) + request = mux.SetURLVars(request, map[string]string{"name": "repotest", "reference": "1.0"}) + request.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + response := httptest.NewRecorder() + + rthdlr.UpdateManifest(response, request) + + resp := response.Result() + defer resp.Body.Close() + + So(resp, ShouldNotBeNil) + + if injected { + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + } else { + So(resp.StatusCode, ShouldEqual, http.StatusCreated) + } + }) }) + }) +} - Convey("Testing group permissions", func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) +func TestInjectTooManyOpenFiles(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port - user1 := "test1" - password1 := "test1" - group1 := "testgroup3" - testString1 := getCredString(user1, password1) - htpasswdPath := test.MakeHtpasswdFileFromString(testString1) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } + dir := t.TempDir() + ctlr := makeController(conf, dir) + conf.Storage.RemoteCache = false - conf.HTTP.Port = port + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - defaultVal := true + rthdlr := api.NewRouteHandler(ctlr) - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } + // create a blob/layer + resp, err := resty.R().Post(baseURL + "/v2/repotest/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc := test.Location(baseURL, resp) + So(loc, ShouldNotBeEmpty) - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - } + // since we are not specifying any prefix i.e provided in config while starting server, + // so it should store repotest to global root dir + _, err = os.Stat(path.Join(dir, "repotest")) + So(err, ShouldBeNil) - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - group1: { - Users: []string{user1}, - }, - }, - Repositories: config.Repositories{ - repoName: config.PolicyGroup{ - Policies: []config.Policy{ - { - Groups: []string{group1}, - Actions: []string{"read", "create"}, - }, - }, - DefaultPolicy: []string{}, - }, - }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, - }, - } + resp, err = resty.R().Get(loc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + content := []byte("this is a dummy blob") + digest := godigest.FromBytes(content) + So(digest, ShouldNotBeNil) - ctlr := makeController(conf, tempDir) + // monolithic blob upload + injected := inject.InjectFailure(2) + if injected { + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, loc, bytes.NewReader(content)) + tokens := strings.Split(loc, "/") + request = mux.SetURLVars(request, map[string]string{"name": "repotest", "session_id": tokens[len(tokens)-1]}) + q := request.URL.Query() + q.Add("digest", digest.String()) + request.URL.RawQuery = q.Encode() + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Content-Length", fmt.Sprintf("%d", len(content))) + response := httptest.NewRecorder() - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + rthdlr.UpdateBlobUpload(response, request) - img := CreateRandomImage() + resp := response.Result() + defer resp.Body.Close() - err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) + So(resp, ShouldNotBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + } else { + resp, err = resty.R().SetQueryParam("digest", digest.String()). + SetHeader("Content-Type", "application/octet-stream"). + SetBody(content).Put(loc) So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - query := ` - { - GlobalSearch(query:"testrepo"){ - Repos { - Name - NewestImage { - RepoName - Tag - } - } - } - }` - resp, err := resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.FullSearchPrefix + - "?query=" + url.QueryEscape(query)) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, 200) - }) + blobLoc := resp.Header().Get("Location") + So(blobLoc, ShouldNotBeEmpty) + So(resp.Header().Get("Content-Length"), ShouldEqual, "0") + So(resp.Header().Get(constants.DistContentDigestKey), ShouldNotBeEmpty) + } - Convey("Testing group permissions when the user is part of more groups with different permissions", func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + // upload image config blob + resp, err = resty.R().Post(baseURL + "/v2/repotest/blobs/uploads/") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + loc = test.Location(baseURL, resp) + cblob, cdigest := GetRandomImageConfig() + + resp, err = resty.R(). + SetContentLength(true). + SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - user1 := "test2" - password1 := "test2" - group1 := "testgroup1" - group2 := "secondtestgroup" - testString1 := getCredString(user1, password1) - htpasswdPath := test.MakeHtpasswdFileFromString(testString1) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, + // create a manifest + manifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cdigest, + Size: int64(len(cblob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest, + Size: int64(len(content)), }, - } + }, + } + manifest.SchemaVersion = 2 + content, err = json.Marshal(manifest) + So(err, ShouldBeNil) + digest = godigest.FromBytes(content) + So(digest, ShouldNotBeNil) - conf.HTTP.Port = port + // Testing router path: @Router /v2/{name}/manifests/{reference} [put] + //nolint:lll // gofumpt conflicts with lll + Convey("Uploading an image manifest blob (when injected simulates that PutImageManifest failed due to 'too many open files' error)", func() { + injected := inject.InjectFailure(2) - defaultVal := true + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(content)) + request = mux.SetURLVars(request, map[string]string{"name": "repotest", "reference": "1.0"}) + request.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + response := httptest.NewRecorder() - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } + rthdlr.UpdateManifest(response, request) - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - } + resp := response.Result() + So(resp, ShouldNotBeNil) + defer resp.Body.Close() - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - group1: { - Users: []string{user1}, - }, - }, - Repositories: config.Repositories{ - repoName: config.PolicyGroup{ - Policies: []config.Policy{ - { - Groups: []string{group1}, - Actions: []string{"delete"}, - }, - { - Groups: []string{group2}, - Actions: []string{"read", "create"}, - }, - }, - DefaultPolicy: []string{}, - }, - }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, - }, + if injected { + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + } else { + So(resp.StatusCode, ShouldEqual, http.StatusCreated) } + }) + Convey("when injected simulates a `too many open files` error inside PutImageManifest method of img store", func() { + injected := inject.InjectFailure(2) - ctlr := makeController(conf, tempDir) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(content)) + request = mux.SetURLVars(request, map[string]string{"name": "repotest", "reference": "1.0"}) + request.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + response := httptest.NewRecorder() - img := CreateRandomImage() + rthdlr.UpdateManifest(response, request) - err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) - So(err, ShouldNotBeNil) - }) + resp := response.Result() + defer resp.Body.Close() - Convey("Testing group permissions when group has less permissions than user", func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + So(resp, ShouldNotBeNil) - user1 := "test3" - password1 := "test3" - group1 := "testgroup" - testString1 := getCredString(user1, password1) - htpasswdPath := test.MakeHtpasswdFileFromString(testString1) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, + if injected { + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + } else { + So(resp.StatusCode, ShouldEqual, http.StatusCreated) } + }) + Convey("code coverage: error inside PutImageManifest method of img store (unable to marshal JSON)", func() { + injected := inject.InjectFailure(1) - conf.HTTP.Port = port + request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(content)) + request = mux.SetURLVars(request, map[string]string{"name": "repotest", "reference": "1.0"}) + request.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + response := httptest.NewRecorder() - defaultVal := true + rthdlr.UpdateManifest(response, request) - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } + resp := response.Result() + defer resp.Body.Close() - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - } + So(resp, ShouldNotBeNil) - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - group1: { - Users: []string{user1}, - }, - }, - Repositories: config.Repositories{ - repoName: config.PolicyGroup{ - Policies: []config.Policy{ - { - Groups: []string{group1}, - Actions: []string{"delete"}, - }, - { - Users: []string{user1}, - Actions: []string{"read", "create", "delete"}, - }, - }, - DefaultPolicy: []string{}, - }, - }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, - }, + if injected { + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + } else { + So(resp.StatusCode, ShouldEqual, http.StatusCreated) } + }) - ctlr := makeController(conf, tempDir) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + Convey("when index.json is not in json format", func() { + resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(content).Put(baseURL + "/v2/repotest/manifests/v1.0") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + digestHdr := resp.Header().Get(constants.DistContentDigestKey) + So(digestHdr, ShouldNotBeEmpty) + So(digestHdr, ShouldEqual, digest.String()) - img := CreateRandomImage() + indexFile := path.Join(dir, "repotest", "index.json") + _, err = os.Stat(indexFile) + So(err, ShouldBeNil) + indexContent := []byte(`not a JSON content`) + err = os.WriteFile(indexFile, indexContent, 0o600) + So(err, ShouldBeNil) - err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) + resp, err = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(content).Put(baseURL + "/v2/repotest/manifests/v1.1") So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) }) + }) +} + +func TestGCSignaturesAndUntaggedManifestsWithMetaDB(t *testing.T) { + Convey("Make controller", t, func() { + Convey("Garbage collect signatures without subject and manifests without tags", func(c C) { + repoName := "testrepo" //nolint:goconst + tag := "0.0.1" - Convey("Testing group permissions when user has less permissions than group", func(c C) { - conf := config.New() port := test.GetFreePort() baseURL := test.GetBaseURL(port) - - user1 := "test4" - password1 := "test4" - group1 := "testgroup1" - testString1 := getCredString(user1, password1) - htpasswdPath := test.MakeHtpasswdFileFromString(testString1) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - + conf := config.New() conf.HTTP.Port = port - defaultVal := true - + value := true searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + BaseConfig: extconf.BaseConfig{Enable: &value}, } + // added search extensions so that metaDB is initialized and its tested in GC logic conf.Extensions = &extconf.ExtensionConfig{ Search: searchConfig, } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - group1: { - Users: []string{user1}, - }, - }, - Repositories: config.Repositories{ - repoName: config.PolicyGroup{ - Policies: []config.Policy{ - { - Groups: []string{group1}, - Actions: []string{"read", "create", "delete"}, - }, - { - Users: []string{user1}, - Actions: []string{"delete"}, - }, - }, - DefaultPolicy: []string{}, - }, - }, - AdminPolicy: config.Policy{ - Users: []string{}, - Actions: []string{}, - }, - } + ctlr := makeController(conf, t.TempDir()) + + dir := t.TempDir() + ctlr.Config.Storage.RootDirectory = dir + ctlr.Config.Storage.GC = true + ctlr.Config.Storage.GCDelay = 1 * time.Millisecond + ctlr.Config.Storage.UntaggedImageRetentionDelay = 1 * time.Millisecond - ctlr := makeController(conf, tempDir) + ctlr.Config.Storage.Dedupe = false cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) + cm.StartServer() + cm.WaitServerToBeReady(port) defer cm.StopServer() - img := CreateRandomImage() + img := CreateDefaultImage() - err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) + err := UploadImage(img, baseURL, repoName, tag) So(err, ShouldBeNil) - }) - - Convey("Testing group permissions on admin policy", func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - - user1 := "test5" - password1 := "test5" - group1 := "testgroup2" - testString1 := getCredString(user1, password1) - htpasswdPath := test.MakeHtpasswdFileFromString(testString1) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } - - conf.HTTP.Port = port - - defaultVal := true - - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } - - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - group1: { - Users: []string{user1}, - }, - }, - Repositories: config.Repositories{}, - AdminPolicy: config.Policy{ - Groups: []string{group1}, - Actions: []string{"read", "create"}, + gc := gc.NewGarbageCollect(ctlr.StoreController.DefaultStore, ctlr.MetaDB, + gc.Options{ + Referrers: ctlr.Config.Storage.GCReferrers, + Delay: ctlr.Config.Storage.GCDelay, + RetentionDelay: ctlr.Config.Storage.UntaggedImageRetentionDelay, }, - } - - ctlr := makeController(conf, tempDir) - - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() - - img := CreateRandomImage() + ctlr.Log) - err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), user1, password1) + resp, err := resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, tag)) So(err, ShouldBeNil) - }) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + digest := godigest.FromBytes(resp.Body()) + So(digest, ShouldNotBeEmpty) - Convey("Testing group permissions on anonymous policy", func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + cwd, err := os.Getwd() + So(err, ShouldBeNil) + defer func() { _ = os.Chdir(cwd) }() + tdir := t.TempDir() + _ = os.Chdir(tdir) - conf.HTTP.Port = port + // generate a keypair + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) - defaultVal := true - group1 := group - user1 := username - password1 := passphrase - - testString1 := getCredString(user1, password1) - htpasswdPath := test.MakeHtpasswdFileFromString(testString1) - defer os.Remove(htpasswdPath) - conf.HTTP.Auth = &config.AuthConfig{ - HTPasswd: config.AuthHTPasswd{ - Path: htpasswdPath, - }, - } + image := fmt.Sprintf("localhost:%s/%s@%s", port, repoName, digest.String()) - searchConfig := &extconf.SearchConfig{ - BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, - } + annotations := []string{fmt.Sprintf("tag=%s", tag)} - conf.Extensions = &extconf.ExtensionConfig{ - Search: searchConfig, - } - conf.HTTP.AccessControl = &config.AccessControlConfig{ - Groups: config.Groups{ - group1: { - Users: []string{user1}, - }, - }, - Repositories: config.Repositories{ - repoName: config.PolicyGroup{ - Policies: []config.Policy{ - { - Groups: []string{group1}, - Actions: []string{"read", "create", "delete"}, - }, - { - Users: []string{user1}, - Actions: []string{"delete"}, - }, - }, - DefaultPolicy: []string{}, - AnonymousPolicy: []string{"read", "create"}, - }, + // sign the image + err = sign.SignCmd(&options.RootOptions{Verbose: true, Timeout: 1 * time.Minute}, + options.KeyOpts{KeyRef: path.Join(tdir, "cosign.key"), PassFunc: generate.GetPass}, + options.SignOptions{ + Registry: options.RegistryOptions{AllowInsecure: true}, + AnnotationOptions: options.AnnotationOptions{Annotations: annotations}, + Upload: true, }, - } + []string{image}) - ctlr := makeController(conf, tempDir) + So(err, ShouldBeNil) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + signature.NotationPathLock.Lock() + defer signature.NotationPathLock.Unlock() - img := CreateRandomImage() + signature.LoadNotationPath(tdir) - err = UploadImageWithBasicAuth(img, baseURL, repoName, img.DigestStr(), "", "") + // generate a keypair + err = signature.GenerateNotationCerts(tdir, "good") So(err, ShouldBeNil) - }) - }) -} - -func TestDistSpecExtensions(t *testing.T) { - Convey("start zot server with search, ui and trust extensions", t, func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) - conf.HTTP.Port = port + // sign the image + err = signature.SignWithNotation("good", image, tdir) + So(err, ShouldBeNil) - defaultVal := true + // get cosign signature manifest + cosignTag := strings.Replace(digest.String(), ":", "-", 1) + "." + remote.SignatureTagSuffix - conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Search = &extconf.SearchConfig{} - conf.Extensions.Search.Enable = &defaultVal - conf.Extensions.Search.CVE = nil - conf.Extensions.UI = &extconf.UIConfig{} - conf.Extensions.UI.Enable = &defaultVal - conf.Extensions.Trust = &extconf.ImageTrustConfig{} - conf.Extensions.Trust.Enable = &defaultVal - conf.Extensions.Trust.Cosign = defaultVal - conf.Extensions.Trust.Notation = defaultVal + resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, cosignTag)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - logFile, err := os.CreateTemp("", "zot-log*.txt") - So(err, ShouldBeNil) - conf.Log.Output = logFile.Name() - defer os.Remove(logFile.Name()) // clean up + cosignDigest := resp.Header().Get(constants.DistContentDigestKey) + So(cosignDigest, ShouldNotBeEmpty) - ctlr := makeController(conf, t.TempDir()) + // get notation signature manifest + resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( + fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + var index ispec.Index - var extensionList distext.ExtensionList + err = json.Unmarshal(resp.Body(), &index) + So(err, ShouldBeNil) + So(len(index.Manifests), ShouldEqual, 1) - resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, 200) - err = json.Unmarshal(resp.Body(), &extensionList) - So(err, ShouldBeNil) - t.Log(extensionList.Extensions) - So(len(extensionList.Extensions), ShouldEqual, 1) - So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 5) - So(extensionList.Extensions[0].Name, ShouldEqual, constants.BaseExtension) - So(extensionList.Extensions[0].URL, ShouldContainSubstring, "_zot.md") - So(extensionList.Extensions[0].Description, ShouldNotBeEmpty) - // Verify the endpoints below are enabled by search - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullSearchPrefix) - // Verify the endpoints below are enabled by trust - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullCosign) - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullNotation) - // Verify the endpint below are enabled by having both the UI and the Search enabled - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullMgmt) - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullUserPrefs) - }) + // shouldn't do anything + err = gc.CleanRepo(repoName) + So(err, ShouldBeNil) - Convey("start zot server with only the search extension enabled", t, func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + // make sure both signatures are stored in repodb + repoMeta, err := ctlr.MetaDB.GetRepoMeta(repoName) + So(err, ShouldBeNil) - conf.HTTP.Port = port + sigMeta := repoMeta.Signatures[digest.String()] + So(len(sigMeta[storage.CosignType]), ShouldEqual, 1) + So(len(sigMeta[storage.NotationType]), ShouldEqual, 1) + So(sigMeta[storage.CosignType][0].SignatureManifestDigest, ShouldEqual, cosignDigest) + So(sigMeta[storage.NotationType][0].SignatureManifestDigest, ShouldEqual, index.Manifests[0].Digest.String()) - defaultVal := true + Convey("Trigger gcNotationSignatures() error", func() { + var refs ispec.Index + err = json.Unmarshal(resp.Body(), &refs) - conf.Extensions = &extconf.ExtensionConfig{} - conf.Extensions.Search = &extconf.SearchConfig{} - conf.Extensions.Search.Enable = &defaultVal + err := os.Chmod(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded()), 0o000) + So(err, ShouldBeNil) - logFile, err := os.CreateTemp("", "zot-log*.txt") - So(err, ShouldBeNil) - conf.Log.Output = logFile.Name() - defer os.Remove(logFile.Name()) // clean up + // trigger gc + img := CreateRandomImage() - ctlr := makeController(conf, t.TempDir()) + err = UploadImage(img, baseURL, repoName, img.DigestStr()) + So(err, ShouldBeNil) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + err = gc.CleanRepo(repoName) + So(err, ShouldNotBeNil) - var extensionList distext.ExtensionList + err = os.Chmod(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded()), 0o755) + So(err, ShouldBeNil) - resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, 200) - err = json.Unmarshal(resp.Body(), &extensionList) - So(err, ShouldBeNil) - t.Log(extensionList.Extensions) - So(len(extensionList.Extensions), ShouldEqual, 1) - So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 2) - So(extensionList.Extensions[0].Name, ShouldEqual, constants.BaseExtension) - So(extensionList.Extensions[0].URL, ShouldContainSubstring, "_zot.md") - So(extensionList.Extensions[0].Description, ShouldNotBeEmpty) - // Verify the endpoints below are enabled by search - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullSearchPrefix) - So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullMgmt) - // Verify the endpoints below are not enabled since trust is not enabled - So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullCosign) - So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullNotation) - // Verify the endpoints below are not enabled since the UI is not enabled - So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullUserPrefs) - }) + content, err := os.ReadFile(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded())) + So(err, ShouldBeNil) + err = os.WriteFile(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded()), []byte("corrupt"), 0o600) //nolint:lll + So(err, ShouldBeNil) - Convey("start zot server with no enabled extensions", t, func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + err = UploadImage(img, baseURL, repoName, tag) + So(err, ShouldBeNil) - conf.HTTP.Port = port + err = gc.CleanRepo(repoName) + So(err, ShouldNotBeNil) - logFile, err := os.CreateTemp("", "zot-log*.txt") - So(err, ShouldBeNil) - conf.Log.Output = logFile.Name() - defer os.Remove(logFile.Name()) // clean up + err = os.WriteFile(path.Join(dir, repoName, "blobs", "sha256", refs.Manifests[0].Digest.Encoded()), content, 0o600) + So(err, ShouldBeNil) + }) - ctlr := makeController(conf, t.TempDir()) + Convey("Overwrite original image, signatures should be garbage-collected", func() { + // push an image without tag + cfg, layers, manifest, err := deprecated.GetImageComponents(2) //nolint:staticcheck + So(err, ShouldBeNil) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + manifestBuf, err := json.Marshal(manifest) + So(err, ShouldBeNil) + untaggedManifestDigest := godigest.FromBytes(manifestBuf) - var extensionList distext.ExtensionList + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, untaggedManifestDigest.String()) + So(err, ShouldBeNil) - resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, 200) - err = json.Unmarshal(resp.Body(), &extensionList) - So(err, ShouldBeNil) - t.Log(extensionList.Extensions) - // Verify all endpoints which are disabled (even signing urls depend on search being enabled) - So(len(extensionList.Extensions), ShouldEqual, 0) - }) + // make sure repoDB reference was added + repoMeta, err := ctlr.MetaDB.GetRepoMeta(repoName) + So(err, ShouldBeNil) - Convey("start minimal zot server", t, func(c C) { - conf := config.New() - port := test.GetFreePort() - baseURL := test.GetBaseURL(port) + _, ok := repoMeta.Referrers[untaggedManifestDigest.String()] + So(ok, ShouldBeTrue) + _, ok = repoMeta.Signatures[untaggedManifestDigest.String()] + So(ok, ShouldBeTrue) + _, ok = repoMeta.Statistics[untaggedManifestDigest.String()] + So(ok, ShouldBeTrue) - conf.HTTP.Port = port + // overwrite image so that signatures will get invalidated and gc'ed + cfg, layers, manifest, err = deprecated.GetImageComponents(3) //nolint:staticcheck + So(err, ShouldBeNil) - logFile, err := os.CreateTemp("", "zot-log*.txt") - So(err, ShouldBeNil) - conf.Log.Output = logFile.Name() - defer os.Remove(logFile.Name()) // clean up + err = UploadImage( + Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + }, baseURL, repoName, tag) + So(err, ShouldBeNil) - ctlr := makeController(conf, t.TempDir()) + manifestBuf, err = json.Marshal(manifest) + So(err, ShouldBeNil) + newManifestDigest := godigest.FromBytes(manifestBuf) - cm := test.NewControllerManager(ctlr) - cm.StartAndWait(port) - defer cm.StopServer() + err = gc.CleanRepo(repoName) + So(err, ShouldBeNil) - var extensionList distext.ExtensionList - resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, 200) + // make sure both signatures are removed from repodb and repo reference for untagged is removed + repoMeta, err = ctlr.MetaDB.GetRepoMeta(repoName) + So(err, ShouldBeNil) - err = json.Unmarshal(resp.Body(), &extensionList) - So(err, ShouldBeNil) - So(len(extensionList.Extensions), ShouldEqual, 0) - }) -} + sigMeta := repoMeta.Signatures[digest.String()] + So(len(sigMeta[storage.CosignType]), ShouldEqual, 0) + So(len(sigMeta[storage.NotationType]), ShouldEqual, 0) -func TestHTTPOptionsResponse(t *testing.T) { - Convey("Test http options response", t, func() { - conf := config.New() - port := test.GetFreePort() - conf.HTTP.Port = port - baseURL := test.GetBaseURL(port) + _, ok = repoMeta.Referrers[untaggedManifestDigest.String()] + So(ok, ShouldBeFalse) + _, ok = repoMeta.Signatures[untaggedManifestDigest.String()] + So(ok, ShouldBeFalse) + _, ok = repoMeta.Statistics[untaggedManifestDigest.String()] + So(ok, ShouldBeFalse) - ctlr := api.NewController(conf) + // both signatures should be gc'ed + resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, cosignTag)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) - firstDir := t.TempDir() + resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( + fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - secondDir := t.TempDir() - defer os.RemoveAll(firstDir) - defer os.RemoveAll(secondDir) + err = json.Unmarshal(resp.Body(), &index) + So(err, ShouldBeNil) + So(len(index.Manifests), ShouldEqual, 0) - ctlr.Config.Storage.RootDirectory = firstDir - subPaths := make(map[string]config.StorageConfig) - subPaths["/a"] = config.StorageConfig{ - RootDirectory: secondDir, - } + resp, err = resty.R().SetQueryParam("artifactType", notreg.ArtifactTypeNotation).Get( + fmt.Sprintf("%s/v2/%s/referrers/%s", baseURL, repoName, newManifestDigest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) - ctlr.Config.Storage.SubPaths = subPaths - ctrlManager := test.NewControllerManager(ctlr) + err = json.Unmarshal(resp.Body(), &index) + So(err, ShouldBeNil) + So(len(index.Manifests), ShouldEqual, 0) - ctrlManager.StartAndWait(port) + // untagged image should also be gc'ed + resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, untaggedManifestDigest)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) + }) + }) - resp, _ := resty.R().Options(baseURL + constants.RoutePrefix + constants.ExtCatalogPrefix) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) + Convey("Do not gc manifests which are part of a multiarch image", func(c C) { + repoName := "testrepo" //nolint:goconst + tag := "0.0.1" - defer ctrlManager.StopServer() - }) -} + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port -func TestGetGithubUserInfo(t *testing.T) { - Convey("github api calls works", t, func() { - mockedHTTPClient := mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetUserEmails, - []github.UserEmail{ - { - Email: github.String("test@test"), - Primary: github.Bool(true), - }, - }, - ), - mock.WithRequestMatch( - mock.GetUserOrgs, - []github.Organization{ - { - Login: github.String("testOrg"), - }, - }, - ), - ) + ctlr := makeController(conf, t.TempDir()) - client := github.NewClient(mockedHTTPClient) + dir := t.TempDir() + ctlr.Config.Storage.RootDirectory = dir + ctlr.Config.Storage.GC = true + ctlr.Config.Storage.GCDelay = 1 * time.Second + ctlr.Config.Storage.UntaggedImageRetentionDelay = 1 * time.Second - _, _, err := api.GetGithubUserInfo(context.Background(), client, log.Logger{}) - So(err, ShouldBeNil) - }) + err := WriteImageToFileSystem(CreateDefaultImage(), repoName, tag, + ociutils.GetDefaultStoreController(dir, ctlr.Log)) + So(err, ShouldBeNil) - Convey("github ListEmails error", t, func() { - mockedHTTPClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserEmails, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mock.WriteError( - w, - http.StatusInternalServerError, - "github error", - ) - }), - ), - ) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - client := github.NewClient(mockedHTTPClient) + gc := gc.NewGarbageCollect(ctlr.StoreController.DefaultStore, ctlr.MetaDB, + gc.Options{ + Referrers: ctlr.Config.Storage.GCReferrers, + Delay: ctlr.Config.Storage.GCDelay, + RetentionDelay: ctlr.Config.Storage.UntaggedImageRetentionDelay, + }, ctlr.Log) - _, _, err := api.GetGithubUserInfo(context.Background(), client, log.Logger{}) - So(err, ShouldNotBeNil) - }) + resp, err := resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, tag)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + digest := godigest.FromBytes(resp.Body()) + So(digest, ShouldNotBeEmpty) - Convey("github ListEmails error", t, func() { - mockedHTTPClient := mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetUserEmails, - []github.UserEmail{ - { - Email: github.String("test@test"), - Primary: github.Bool(true), - }, - }, - ), - mock.WithRequestMatchHandler( - mock.GetUserOrgs, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mock.WriteError( - w, - http.StatusInternalServerError, - "github error", - ) - }), - ), - ) + // push an image index and make sure manifests contained by it are not gc'ed + // create an image index on upstream + var index ispec.Index + index.SchemaVersion = 2 + index.MediaType = ispec.MediaTypeImageIndex - client := github.NewClient(mockedHTTPClient) + // upload multiple manifests + for i := 0; i < 4; i++ { + config, layers, manifest, err := deprecated.GetImageComponents(1000 + i) //nolint:staticcheck + So(err, ShouldBeNil) - _, _, err := api.GetGithubUserInfo(context.Background(), client, log.Logger{}) - So(err, ShouldNotBeNil) - }) -} + manifestContent, err := json.Marshal(manifest) + So(err, ShouldBeNil) -func getAllBlobs(imagePath string) []string { - blobList := make([]string, 0) + manifestDigest := godigest.FromBytes(manifestContent) - if !common.DirExists(imagePath) { - return []string{} - } + err = UploadImage( + Image{ + Manifest: manifest, + Config: config, + Layers: layers, + }, baseURL, repoName, manifestDigest.String()) + So(err, ShouldBeNil) - buf, err := os.ReadFile(path.Join(imagePath, "index.json")) - if err != nil { - panic(err) - } + index.Manifests = append(index.Manifests, ispec.Descriptor{ + Digest: manifestDigest, + MediaType: ispec.MediaTypeImageManifest, + Size: int64(len(manifestContent)), + }) + } - var index ispec.Index - if err := json.Unmarshal(buf, &index); err != nil { - panic(err) - } + content, err := json.Marshal(index) + So(err, ShouldBeNil) + indexDigest := godigest.FromBytes(content) + So(indexDigest, ShouldNotBeNil) - var digest godigest.Digest + time.Sleep(1 * time.Second) + // upload image index + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + SetBody(content).Put(baseURL + fmt.Sprintf("/v2/%s/manifests/latest", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - for _, m := range index.Manifests { - digest = m.Digest - blobList = append(blobList, digest.Encoded()) - p := path.Join(imagePath, "blobs", digest.Algorithm().String(), digest.Encoded()) + err = gc.CleanRepo(repoName) + So(err, ShouldBeNil) - buf, err = os.ReadFile(p) + resp, err = resty.R().SetHeader("Content-Type", ispec.MediaTypeImageIndex). + Get(baseURL + fmt.Sprintf("/v2/%s/manifests/latest", repoName)) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + So(resp.Body(), ShouldNotBeEmpty) + So(resp.Header().Get("Content-Type"), ShouldNotBeEmpty) - if err != nil { - panic(err) - } + // make sure manifests which are part of image index are not gc'ed + for _, manifest := range index.Manifests { + resp, err = resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, manifest.Digest.String())) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + } + }) + }) +} - var manifest ispec.Manifest - if err := json.Unmarshal(buf, &manifest); err != nil { - panic(err) - } +func TestPeriodicGC(t *testing.T) { + Convey("Periodic gc enabled for default store", t, func() { + repoName := "testrepo" //nolint:goconst - blobList = append(blobList, manifest.Config.Digest.Encoded()) + port := test.GetFreePort() + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RemoteCache = false - for _, layer := range manifest.Layers { - blobList = append(blobList, layer.Digest.Encoded()) - } - } + logFile, err := os.CreateTemp("", "zot-log*.txt") + So(err, ShouldBeNil) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up - return blobList -} + ctlr := api.NewController(conf) + dir := t.TempDir() + ctlr.Config.Storage.RootDirectory = dir + ctlr.Config.Storage.Dedupe = false + ctlr.Config.Storage.GC = true + ctlr.Config.Storage.GCInterval = 1 * time.Hour + ctlr.Config.Storage.GCDelay = 1 * time.Second -func getAllManifests(imagePath string) []string { - manifestList := make([]string, 0) + err = WriteImageToFileSystem(CreateDefaultImage(), repoName, "0.0.1", + ociutils.GetDefaultStoreController(dir, ctlr.Log)) + So(err, ShouldBeNil) - if !common.DirExists(imagePath) { - return []string{} - } + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - buf, err := os.ReadFile(path.Join(imagePath, "index.json")) - if err != nil { - panic(err) - } + time.Sleep(5000 * time.Millisecond) - var index ispec.Index - if err := json.Unmarshal(buf, &index); err != nil { - panic(err) - } + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + So(string(data), ShouldContainSubstring, + "\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":3600000000000") + So(string(data), ShouldContainSubstring, + fmt.Sprintf("executing GC of orphaned blobs for %s", path.Join(ctlr.StoreController.DefaultStore.RootDir(), repoName))) //nolint:lll + So(string(data), ShouldContainSubstring, + fmt.Sprintf("GC successfully completed for %s", path.Join(ctlr.StoreController.DefaultStore.RootDir(), repoName))) //nolint:lll + }) - var digest godigest.Digest + Convey("Periodic GC enabled for substore", t, func() { + port := test.GetFreePort() + conf := config.New() + conf.HTTP.Port = port - for _, m := range index.Manifests { - digest = m.Digest - manifestList = append(manifestList, digest.Encoded()) - } + logFile, err := os.CreateTemp("", "zot-log*.txt") + So(err, ShouldBeNil) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up - return manifestList -} + dir := t.TempDir() + ctlr := makeController(conf, dir) + subDir := t.TempDir() -func makeController(conf *config.Config, dir string) *api.Controller { - ctlr := api.NewController(conf) + subPaths := make(map[string]config.StorageConfig) - ctlr.Config.Storage.RootDirectory = dir + subPaths["/a"] = config.StorageConfig{ + RootDirectory: subDir, GC: true, GCDelay: 1 * time.Second, + UntaggedImageRetentionDelay: 1 * time.Second, GCInterval: 24 * time.Hour, RemoteCache: false, Dedupe: false, + } //nolint:lll // gofumpt conflicts with lll + ctlr.Config.Storage.Dedupe = false + ctlr.Config.Storage.SubPaths = subPaths - return ctlr -} + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() -func RunAuthorizationWithMultiplePoliciesTests(t *testing.T, userClient *resty.Client, bobClient *resty.Client, - baseURL string, conf *config.Config, -) { - t.Helper() - - blob := []byte("hello, blob!") - digest := godigest.FromBytes(blob).String() - - // unauthenticated clients should not have access to /v2/, no policy is applied since none exists - resp, err := resty.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, 401) - - repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] - repoPolicy.AnonymousPolicy = append(repoPolicy.AnonymousPolicy, "read") - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy - - // should have access to /v2/, anonymous policy is applied, "read" allowed - resp, err = resty.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // with empty username:password - resp, err = resty.R().SetHeader("Authorization", "Basic Og==").Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // add "test" user to global policy with create permission - repoPolicy.Policies[0].Users = append(repoPolicy.Policies[0].Users, "test") - repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create") - - // now it should get 202, user has the permission set on "create" - resp, err = userClient.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := resp.Header().Get("Location") - - // uploading blob should get 201 - resp, err = userClient.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - - // head blob should get 403 without read perm - resp, err = userClient.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - // get tags without read access should get 403 - resp, err = userClient.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - repoPolicy.DefaultPolicy = append(repoPolicy.DefaultPolicy, "read") - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy - - // with read permission should get 200, because default policy allows reading now - resp, err = userClient.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // get tags with default read access should be ok, since the user is now "bob" and default policy is applied - resp, err = bobClient.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // get tags with anonymous read access should be ok - resp, err = resty.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // without create permission should get 403, since "bob" can only read(default policy applied) - resp, err = bobClient.R(). - Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - - // add read permission to user "bob" - conf.HTTP.AccessControl.AdminPolicy.Users = append(conf.HTTP.AccessControl.AdminPolicy.Users, "bob") - conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "create") - - // added create permission to user "bob", should be allowed now - resp, err = bobClient.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - - resp, err = resty.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // make sure anonymous is correctly handled when using acCtx (requestcontext package) - catalog := struct { - Repositories []string `json:"repositories"` - }{} - - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(catalog.Repositories, ShouldContain, AuthorizationNamespace) - - resp, err = bobClient.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(catalog.Repositories, ShouldContain, AuthorizationNamespace) - - resp, err = userClient.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(catalog.Repositories, ShouldContain, AuthorizationNamespace) - - // no policy - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = config.PolicyGroup{} - - // no policies, so no anonymous allowed - resp, err = resty.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) - - // bob is admin so he can read - resp, err = bobClient.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(catalog.Repositories, ShouldContain, AuthorizationNamespace) - - // test user has no permissions - resp, err = userClient.R().Get(baseURL + "/v2/_catalog") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(len(catalog.Repositories), ShouldEqual, 0) -} + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + // periodic GC is enabled by default for default store with a default interval + So(string(data), ShouldContainSubstring, + "\"GCDelay\":3600000000000,\"GCInterval\":3600000000000,\"") + // periodic GC is enabled for sub store + So(string(data), ShouldContainSubstring, + fmt.Sprintf("\"SubPaths\":{\"/a\":{\"RootDirectory\":\"%s\",\"Dedupe\":false,\"RemoteCache\":false,\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":86400000000000", subDir)) //nolint:lll // gofumpt conflicts with lll + }) -func RunAuthorizationTests(t *testing.T, client *resty.Client, baseURL string, conf *config.Config) { - t.Helper() + Convey("Periodic gc error", t, func() { + repoName := "testrepo" //nolint:goconst - Convey("run authorization tests", func() { - blob := []byte("hello, blob!") - digest := godigest.FromBytes(blob).String() + port := test.GetFreePort() + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RemoteCache = false - // unauthenticated clients should not have access to /v2/ - resp, err := resty.R().Get(baseURL + "/v2/") + logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, 401) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up - // everybody should have access to /v2/ - resp, err = client.R().Get(baseURL + "/v2/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + ctlr := api.NewController(conf) + dir := t.TempDir() + ctlr.Config.Storage.RootDirectory = dir + ctlr.Config.Storage.Dedupe = false - // everybody should have access to /v2/_catalog - resp, err = client.R().Get(baseURL + constants.RoutePrefix + constants.ExtCatalogPrefix) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - var apiErr apiErr.Error - err = json.Unmarshal(resp.Body(), &apiErr) - So(err, ShouldBeNil) + ctlr.Config.Storage.GC = true + ctlr.Config.Storage.GCInterval = 1 * time.Hour + ctlr.Config.Storage.GCDelay = 1 * time.Second - // should get 403 without create - resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") + err = WriteImageToFileSystem(CreateDefaultImage(), repoName, "0.0.1", + ociutils.GetDefaultStoreController(dir, ctlr.Log)) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - // first let's use global based policies - // add test user to global policy with create perm - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users, "test") //nolint:lll // gofumpt conflicts with lll + So(os.Chmod(dir, 0o000), ShouldBeNil) - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + defer func() { + So(os.Chmod(dir, 0o755), ShouldBeNil) + }() - // now it should get 202 - resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc := resp.Header().Get("Location") + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - // uploading blob should get 201 - resp, err = client.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + time.Sleep(5000 * time.Millisecond) - // head blob should get 403 without read perm - resp, err = client.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + data, err := os.ReadFile(logFile.Name()) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + So(string(data), ShouldContainSubstring, + "\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":3600000000000") + So(string(data), ShouldContainSubstring, "failure walking storage root-dir") //nolint:lll + }) +} - // get tags without read access should get 403 - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) +func TestDistSpecExtensions(t *testing.T) { + Convey("start zot server with search, ui and trust extensions", t, func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) - // get tags with read access should get 200 - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "read") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.Port = port - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + defaultVal := true - // head blob should get 200 now - resp, err = client.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal + conf.Extensions.Search.CVE = nil + conf.Extensions.UI = &extconf.UIConfig{} + conf.Extensions.UI.Enable = &defaultVal + conf.Extensions.Trust = &extconf.ImageTrustConfig{} + conf.Extensions.Trust.Enable = &defaultVal + conf.Extensions.Trust.Cosign = defaultVal + conf.Extensions.Trust.Notation = defaultVal - // get blob should get 200 now - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up - // delete blob should get 403 without delete perm - resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + ctlr := makeController(conf, t.TempDir()) - // add delete perm on repo - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "delete") //nolint:lll // gofumpt conflicts with lll + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + var extensionList distext.ExtensionList - // delete blob should get 202 - resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + So(resp.StatusCode(), ShouldEqual, 200) + err = json.Unmarshal(resp.Body(), &extensionList) + So(err, ShouldBeNil) + t.Log(extensionList.Extensions) + So(len(extensionList.Extensions), ShouldEqual, 1) + So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 5) + So(extensionList.Extensions[0].Name, ShouldEqual, constants.BaseExtension) + So(extensionList.Extensions[0].URL, ShouldContainSubstring, "_zot.md") + So(extensionList.Extensions[0].Description, ShouldNotBeEmpty) + // Verify the endpoints below are enabled by search + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullSearchPrefix) + // Verify the endpoints below are enabled by trust + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullCosign) + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullNotation) + // Verify the endpint below are enabled by having both the UI and the Search enabled + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullMgmt) + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullUserPrefs) + }) - // now let's use only repository based policies - // add test user to repo's policy with create perm - // longest path matching should match the repo and not **/* - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = config.PolicyGroup{ - Policies: []config.Policy{ - { - Users: []string{}, - Actions: []string{}, - }, - }, - DefaultPolicy: []string{}, - } + Convey("start zot server with only the search extension enabled", t, func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test") //nolint:lll // gofumpt conflicts with lll - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.Port = port - // now it should get 202 - resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc = resp.Header().Get("Location") + defaultVal := true - // uploading blob should get 201 - resp, err = client.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Search = &extconf.SearchConfig{} + conf.Extensions.Search.Enable = &defaultVal - // head blob should get 403 without read perm - resp, err = client.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up - // get tags without read access should get 403 - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + ctlr := makeController(conf, t.TempDir()) - // get tags with read access should get 200 - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "read") //nolint:lll // gofumpt conflicts with lll + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + var extensionList distext.ExtensionList - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") + resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - // head blob should get 200 now - resp, err = client.R().Head(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + So(resp.StatusCode(), ShouldEqual, 200) + err = json.Unmarshal(resp.Body(), &extensionList) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + t.Log(extensionList.Extensions) + So(len(extensionList.Extensions), ShouldEqual, 1) + So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 2) + So(extensionList.Extensions[0].Name, ShouldEqual, constants.BaseExtension) + So(extensionList.Extensions[0].URL, ShouldContainSubstring, "_zot.md") + So(extensionList.Extensions[0].Description, ShouldNotBeEmpty) + // Verify the endpoints below are enabled by search + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullSearchPrefix) + So(extensionList.Extensions[0].Endpoints, ShouldContain, constants.FullMgmt) + // Verify the endpoints below are not enabled since trust is not enabled + So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullCosign) + So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullNotation) + // Verify the endpoints below are not enabled since the UI is not enabled + So(extensionList.Extensions[0].Endpoints, ShouldNotContain, constants.FullUserPrefs) + }) - // get blob should get 200 now - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + Convey("start zot server with no enabled extensions", t, func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) - // delete blob should get 403 without delete perm - resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) + conf.HTTP.Port = port + + logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up - // add delete perm on repo - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "delete") //nolint:lll // gofumpt conflicts with lll + ctlr := makeController(conf, t.TempDir()) - // delete blob should get 202 - resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - // remove permissions on **/* so it will not interfere with zot-test namespace - repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] - repoPolicy.Policies = []config.Policy{} - repoPolicy.DefaultPolicy = []string{} - conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + var extensionList distext.ExtensionList - // get manifest should get 403, we don't have perm at all on this repo - resp, err = client.R().Get(baseURL + "/v2/zot-test/manifests/0.0.1") + resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + So(resp.StatusCode(), ShouldEqual, 200) + err = json.Unmarshal(resp.Body(), &extensionList) + So(err, ShouldBeNil) + t.Log(extensionList.Extensions) + // Verify all endpoints which are disabled (even signing urls depend on search being enabled) + So(len(extensionList.Extensions), ShouldEqual, 0) + }) - // add read perm on repo - conf.HTTP.AccessControl.Repositories["zot-test"] = config.PolicyGroup{Policies: []config.Policy{ - { - Users: []string{"test"}, - Actions: []string{"read"}, - }, - }, DefaultPolicy: []string{}} + Convey("start minimal zot server", t, func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) - /* we have 4 images(authz/image, golang, zot-test, zot-cve-test) in storage, - but because at this point we only have read access - in authz/image and zot-test, we should get only that when listing repositories*/ - resp, err = client.R().Get(baseURL + constants.RoutePrefix + constants.ExtCatalogPrefix) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - err = json.Unmarshal(resp.Body(), &apiErr) + conf.HTTP.Port = port + + logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up - catalog := struct { - Repositories []string `json:"repositories"` - }{} + ctlr := makeController(conf, t.TempDir()) - err = json.Unmarshal(resp.Body(), &catalog) - So(err, ShouldBeNil) - So(len(catalog.Repositories), ShouldEqual, 2) - So(catalog.Repositories, ShouldContain, "zot-test") - So(catalog.Repositories, ShouldContain, AuthorizationNamespace) + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() - // get manifest should get 200 now - resp, err = client.R().Get(baseURL + "/v2/zot-test/manifests/0.0.1") + var extensionList distext.ExtensionList + resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) So(err, ShouldBeNil) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - - manifestBlob := resp.Body() - var manifest ispec.Manifest + So(resp.StatusCode(), ShouldEqual, 200) - err = json.Unmarshal(manifestBlob, &manifest) + err = json.Unmarshal(resp.Body(), &extensionList) So(err, ShouldBeNil) + So(len(extensionList.Extensions), ShouldEqual, 0) + }) +} - // put manifest should get 403 without create perm - resp, err = client.R(). - SetBody(manifestBlob). - Put(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) +func TestHTTPOptionsResponse(t *testing.T) { + Convey("Test http options response", t, func() { + conf := config.New() + port := test.GetFreePort() + conf.HTTP.Port = port + baseURL := test.GetBaseURL(port) - // add create perm on repo - conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + ctlr := api.NewController(conf) - // should get 201 with create perm - resp, err = client.R(). - SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(manifestBlob). - Put(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + firstDir := t.TempDir() - // create update config and post it. - cblob, cdigest := GetRandomImageConfig() + secondDir := t.TempDir() + defer os.RemoveAll(firstDir) + defer os.RemoveAll(secondDir) - resp, err = client.R(). - Post(baseURL + "/v2/zot-test/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc = test.Location(baseURL, resp) + ctlr.Config.Storage.RootDirectory = firstDir + subPaths := make(map[string]config.StorageConfig) + subPaths["/a"] = config.StorageConfig{ + RootDirectory: secondDir, + } - // uploading blob should get 201 - resp, err = client.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", cdigest.String()). - SetBody(cblob). - Put(loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + ctlr.Config.Storage.SubPaths = subPaths + ctrlManager := test.NewControllerManager(ctlr) - // create updated layer and post it - updateBlob := []byte("Hello, blob update!") + ctrlManager.StartAndWait(port) - resp, err = client.R().Post(baseURL + "/v2/zot-test/blobs/uploads/") - So(err, ShouldBeNil) + resp, _ := resty.R().Options(baseURL + constants.RoutePrefix + constants.ExtCatalogPrefix) So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc = test.Location(baseURL, resp) + So(resp.StatusCode(), ShouldEqual, http.StatusNoContent) - // uploading blob should get 201 - resp, err = client.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(updateBlob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", string(godigest.FromBytes(updateBlob))). - SetBody(updateBlob). - Put(loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + defer ctrlManager.StopServer() + }) +} - updatedManifest := ispec.Manifest{ - Config: ispec.Descriptor{ - MediaType: "application/vnd.oci.image.config.v1+json", - Digest: cdigest, - Size: int64(len(cblob)), - }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: godigest.FromBytes(updateBlob), - Size: int64(len(updateBlob)), +func TestGetGithubUserInfo(t *testing.T) { + Convey("github api calls works", t, func() { + mockedHTTPClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserEmails, + []github.UserEmail{ + { + Email: github.String("test@test"), + Primary: github.Bool(true), + }, }, - }, - } - updatedManifest.SchemaVersion = 2 - updatedManifestBlob, err := json.Marshal(updatedManifest) - So(err, ShouldBeNil) + ), + mock.WithRequestMatch( + mock.GetUserOrgs, + []github.Organization{ + { + Login: github.String("testOrg"), + }, + }, + ), + ) - // update manifest should get 403 without update perm - resp, err = client.R(). - SetBody(updatedManifestBlob). - Put(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + client := github.NewClient(mockedHTTPClient) - // get the manifest and check if it's the old one - resp, err = client.R().Get(baseURL + "/v2/zot-test/manifests/0.0.2") + _, _, err := api.GetGithubUserInfo(context.Background(), client, log.Logger{}) So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldResemble, manifestBlob) + }) - // add update perm on repo - conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions, "update") //nolint:lll // gofumpt conflicts with lll + Convey("github ListEmails error", t, func() { + mockedHTTPClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserEmails, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mock.WriteError( + w, + http.StatusInternalServerError, + "github error", + ) + }), + ), + ) - // update manifest should get 201 with update perm - resp, err = client.R(). - SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(updatedManifestBlob). - Put(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + client := github.NewClient(mockedHTTPClient) - // get the manifest and check if it's the new updated one - resp, err = client.R().Get(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) - So(resp.Body(), ShouldResemble, updatedManifestBlob) - - // now use default repo policy - conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{} - repoPolicy = conf.HTTP.AccessControl.Repositories["zot-test"] - repoPolicy.DefaultPolicy = []string{"update"} - conf.HTTP.AccessControl.Repositories["zot-test"] = repoPolicy - - // update manifest should get 201 with update perm on repo's default policy - resp, err = client.R(). - SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(manifestBlob). - Put(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + _, _, err := api.GetGithubUserInfo(context.Background(), client, log.Logger{}) + So(err, ShouldNotBeNil) + }) - // with default read on repo should still get 200 - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = []string{} - repoPolicy = conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] - repoPolicy.DefaultPolicy = []string{"read"} - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + Convey("github ListEmails error", t, func() { + mockedHTTPClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserEmails, + []github.UserEmail{ + { + Email: github.String("test@test"), + Primary: github.Bool(true), + }, + }, + ), + mock.WithRequestMatchHandler( + mock.GetUserOrgs, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mock.WriteError( + w, + http.StatusInternalServerError, + "github error", + ) + }), + ), + ) - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + client := github.NewClient(mockedHTTPClient) - // upload blob without user create but with default create should get 200 - repoPolicy.DefaultPolicy = append(repoPolicy.DefaultPolicy, "create") - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + _, _, err := api.GetGithubUserInfo(context.Background(), client, log.Logger{}) + So(err, ShouldNotBeNil) + }) +} - resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) +func getAllBlobs(imagePath string) []string { + blobList := make([]string, 0) - // remove per repo policy - repoPolicy = conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] - repoPolicy.Policies = []config.Policy{} - repoPolicy.DefaultPolicy = []string{} - conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + if !common.DirExists(imagePath) { + return []string{} + } - repoPolicy = conf.HTTP.AccessControl.Repositories["zot-test"] - repoPolicy.Policies = []config.Policy{} - repoPolicy.DefaultPolicy = []string{} - conf.HTTP.AccessControl.Repositories["zot-test"] = repoPolicy + buf, err := os.ReadFile(path.Join(imagePath, "index.json")) + if err != nil { + panic(err) + } - resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + var index ispec.Index + if err := json.Unmarshal(buf, &index); err != nil { + panic(err) + } - // whithout any perm should get 403 - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + var digest godigest.Digest - // add read perm - conf.HTTP.AccessControl.AdminPolicy.Users = append(conf.HTTP.AccessControl.AdminPolicy.Users, "test") - conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "read") + for _, m := range index.Manifests { + digest = m.Digest + blobList = append(blobList, digest.Encoded()) + p := path.Join(imagePath, "blobs", digest.Algorithm().String(), digest.Encoded()) - // with read perm should get 200 - resp, err = client.R().Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusOK) + buf, err = os.ReadFile(p) - // without create perm should 403 - resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + if err != nil { + panic(err) + } - // add create perm - conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "create") + var manifest ispec.Manifest + if err := json.Unmarshal(buf, &manifest); err != nil { + panic(err) + } - // with create perm should get 202 - resp, err = client.R().Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) - loc = resp.Header().Get("Location") + blobList = append(blobList, manifest.Config.Digest.Encoded()) - // uploading blob should get 201 - resp, err = client.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + for _, layer := range manifest.Layers { + blobList = append(blobList, layer.Digest.Encoded()) + } + } - // without delete perm should 403 - resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + return blobList +} - // add delete perm - conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "delete") +func getAllManifests(imagePath string) []string { + manifestList := make([]string, 0) - // with delete perm should get http.StatusAccepted - resp, err = client.R().Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) + if !common.DirExists(imagePath) { + return []string{} + } - // without update perm should 403 - resp, err = client.R(). - SetBody(manifestBlob). - Put(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + buf, err := os.ReadFile(path.Join(imagePath, "index.json")) + if err != nil { + panic(err) + } - // add update perm - conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "update") + var index ispec.Index + if err := json.Unmarshal(buf, &index); err != nil { + panic(err) + } - // update manifest should get 201 with update perm - resp, err = client.R(). - SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(manifestBlob). - Put(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusCreated) + var digest godigest.Digest - conf.HTTP.AccessControl = &config.AccessControlConfig{} + for _, m := range index.Manifests { + digest = m.Digest + manifestList = append(manifestList, digest.Encoded()) + } - resp, err = client.R(). - SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(manifestBlob). - Put(baseURL + "/v2/zot-test/manifests/0.0.2") - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - }) + return manifestList +} + +func makeController(conf *config.Config, dir string) *api.Controller { + ctlr := api.NewController(conf) + + ctlr.Config.Storage.RootDirectory = dir + + return ctlr } func getEmptyImageConfig() ([]byte, godigest.Digest) { diff --git a/pkg/api/routes.go b/pkg/api/routes.go index 1db9a8044b..6cd5d9a73b 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -183,7 +183,7 @@ func (rh *RouteHandler) SetupRoutes() { pprof.SetupPprofRoutes(rh.c.Config, prefixedRouter, authHandler, rh.c.Log) // Preconditions for enabling the actual extension routes are part of extensions themselves - ext.SetupMetricsRoutes(rh.c.Config, rh.c.Router, authHandler, rh.c.Log, rh.c.Metrics) + ext.SetupMetricsRoutes(rh.c.Config, rh.c.Router, authHandler, MetricsAuthzHandler(rh.c), rh.c.Log, rh.c.Metrics) ext.SetupSearchRoutes(rh.c.Config, prefixedRouter, rh.c.StoreController, rh.c.MetaDB, rh.c.CveScanner, rh.c.Log) ext.SetupImageTrustRoutes(rh.c.Config, prefixedRouter, rh.c.MetaDB, rh.c.Log) diff --git a/pkg/extensions/README.md b/pkg/extensions/README.md index a6cb9ba33a..6f9df76ba3 100644 --- a/pkg/extensions/README.md +++ b/pkg/extensions/README.md @@ -30,9 +30,9 @@ package extensions IsAdmin bool Username string Groups []string - } + } ``` - This data can then be accessed from the request context so that every extension can apply its own authorization logic, if needed . + This data can then be accessed from the request context so that every extension can apply its own authorization logic, if needed . - when a new extension comes out, the developer should also write some blackbox tests, where a binary that contains the new extension should be tested in a real usage scenario. See [test/blackbox](test/blackbox/sync.bats) folder for multiple extensions examples. @@ -40,6 +40,6 @@ package extensions - with every new extension, you should modify the EXTENSIONS variable in Makefile by adding the new extension. The EXTENSIONS variable represents all extensions and is used in Make targets that require them all (e.g make test). -- the available extensions that can be used at the moment are: sync, scrub, metrics, search . +- the available extensions that can be used at the moment are: sync, search, scrub, metrics, lint, ui, mgmt, userprefs, imagetrust . NOTE: When multiple extensions are used, they should be listed in the above presented order. diff --git a/pkg/extensions/extension_metrics.go b/pkg/extensions/extension_metrics.go index 54e71982fa..1837d7ef78 100644 --- a/pkg/extensions/extension_metrics.go +++ b/pkg/extensions/extension_metrics.go @@ -26,13 +26,14 @@ func EnableMetricsExtension(config *config.Config, log log.Logger, rootDir strin } func SetupMetricsRoutes(config *config.Config, router *mux.Router, - authFunc mux.MiddlewareFunc, log log.Logger, metrics monitoring.MetricServer, + authnFunc, authzFunc mux.MiddlewareFunc, log log.Logger, metrics monitoring.MetricServer, ) { log.Info().Msg("setting up metrics routes") if config.IsMetricsEnabled() { extRouter := router.PathPrefix(config.Extensions.Metrics.Prometheus.Path).Subrouter() - extRouter.Use(authFunc) + extRouter.Use(authnFunc) + extRouter.Use(authzFunc) extRouter.Methods("GET").Handler(promhttp.Handler()) } } diff --git a/pkg/extensions/extension_metrics_disabled.go b/pkg/extensions/extension_metrics_disabled.go index 6d280b9ba6..624fc06125 100644 --- a/pkg/extensions/extension_metrics_disabled.go +++ b/pkg/extensions/extension_metrics_disabled.go @@ -22,13 +22,14 @@ func EnableMetricsExtension(config *config.Config, log log.Logger, rootDir strin // SetupMetricsRoutes ... func SetupMetricsRoutes(conf *config.Config, router *mux.Router, - authFunc mux.MiddlewareFunc, log log.Logger, metrics monitoring.MetricServer, + authnFunc, authzFunc mux.MiddlewareFunc, log log.Logger, metrics monitoring.MetricServer, ) { getMetrics := func(w http.ResponseWriter, r *http.Request) { m := metrics.ReceiveMetrics() zcommon.WriteJSON(w, http.StatusOK, m) } - router.Use(authFunc) + router.Use(authnFunc) + router.Use(authzFunc) router.HandleFunc("/metrics", getMetrics).Methods("GET") } diff --git a/pkg/test/skip/skip_test.go b/pkg/test/skip/skip_test.go new file mode 100644 index 0000000000..b266f11cdb --- /dev/null +++ b/pkg/test/skip/skip_test.go @@ -0,0 +1,37 @@ +package skip_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + + tskip "zotregistry.io/zot/pkg/test/skip" +) + +// for code coverage. +func TestSkipS3(t *testing.T) { + envName := "S3MOCK_ENDPOINT" + envVal := os.Getenv(envName) + + if len(envVal) > 0 { + defer os.Setenv(envName, envVal) + err := os.Unsetenv(envName) + assert.Equal(t, err, nil, "Error should be nil") + } + + tskip.SkipS3(t) +} + +func TestSkipDynamo(t *testing.T) { + envName := "DYNAMODBMOCK_ENDPOINT" + envVal := os.Getenv(envName) + + if len(envVal) > 0 { + defer os.Setenv(envName, envVal) + err := os.Unsetenv(envName) + assert.Equal(t, err, nil, "Error should be nil") + } + + tskip.SkipDynamo(t) +} diff --git a/test/blackbox/helpers_metrics.bash b/test/blackbox/helpers_metrics.bash index cce55e5b84..4c225568b8 100644 --- a/test/blackbox/helpers_metrics.bash +++ b/test/blackbox/helpers_metrics.bash @@ -1,3 +1,6 @@ +METRICS_USER=observability +METRICS_PASS=MySecreTPa55 + function metrics_route_check () { local servername="http://127.0.0.1:${1}/metrics" status_code=$(curl --write-out '%{http_code}' ${2} --silent --output /dev/null ${servername}) diff --git a/test/blackbox/metrics.bats b/test/blackbox/metrics.bats index 0d392ac2cf..9a39c664dd 100644 --- a/test/blackbox/metrics.bats +++ b/test/blackbox/metrics.bats @@ -32,6 +32,7 @@ function setup_file() { zot_config_file=${BATS_FILE_TMPDIR}/zot_config.json zot_htpasswd_file=${BATS_FILE_TMPDIR}/zot_htpasswd htpasswd -Bbn ${AUTH_USER} ${AUTH_PASS} >> ${zot_htpasswd_file} + htpasswd -Bbn ${METRICS_USER} ${METRICS_PASS} >> ${zot_htpasswd_file} mkdir -p ${zot_root_dir} touch ${zot_log_file} @@ -48,6 +49,20 @@ function setup_file() { "htpasswd": { "path": "${zot_htpasswd_file}" } + }, + "accessControl": { + "metrics":{ + "users": ["${METRICS_USER}"] + }, + "repositories": { + "**": { + "anonymousPolicy": [ + "read", + "create" + ], + "defaultPolicy": ["read"] + } + } } }, "log": { @@ -80,14 +95,20 @@ function teardown_file() { } @test "unauthorized request to metrics" { +# anonymous policy: metrics endpoint should not be available +# 401 - http.StatusUnauthorized run metrics_route_check 8080 "" 401 [ "$status" -eq 0 ] +# user is not in htpasswd run metrics_route_check 8080 "-u unlucky:wrongpass" 401 [ "$status" -eq 0 ] +# proper user/pass tuple from htpasswd, but user not allowed to access metrics +# 403 - http.StatusForbidden + run metrics_route_check 8080 "-u ${AUTH_USER}:${AUTH_PASS}" 403 + [ "$status" -eq 0 ] } @test "authorized request: metrics enabled" { - run metrics_route_check 8080 "-u ${AUTH_USER}:${AUTH_PASS}" 200 + run metrics_route_check 8080 "-u ${METRICS_USER}:${METRICS_PASS}" 200 [ "$status" -eq 0 ] } - diff --git a/test/blackbox/metrics_minimal.bats b/test/blackbox/metrics_minimal.bats index c693f64ec5..352498840f 100644 --- a/test/blackbox/metrics_minimal.bats +++ b/test/blackbox/metrics_minimal.bats @@ -32,6 +32,7 @@ function setup_file() { zot_config_file=${BATS_FILE_TMPDIR}/zot_config.json zot_htpasswd_file=${BATS_FILE_TMPDIR}/zot_htpasswd htpasswd -Bbn ${AUTH_USER} ${AUTH_PASS} >> ${zot_htpasswd_file} + htpasswd -Bbn ${METRICS_USER} ${METRICS_PASS} >> ${zot_htpasswd_file} mkdir -p ${zot_root_dir} touch ${zot_log_file} @@ -48,6 +49,20 @@ function setup_file() { "htpasswd": { "path": "${zot_htpasswd_file}" } + }, + "accessControl": { + "metrics":{ + "users": ["${METRICS_USER}"] + }, + "repositories": { + "**": { + "anonymousPolicy": [ + "read", + "create" + ], + "defaultPolicy": ["read"] + } + } } }, "log": { @@ -72,13 +87,20 @@ function teardown_file() { } @test "unauthorized request to metrics" { +# anonymous policy: metrics endpoint should not be available +# 401 - http.StatusUnauthorized run metrics_route_check 8080 "" 401 [ "$status" -eq 0 ] +# user is not in htpasswd run metrics_route_check 8080 "-u test:wrongpass" 401 [ "$status" -eq 0 ] +# proper user/pass tuple from htpasswd, but user not allowed to access metrics +# 403 - http.StatusForbidden + run metrics_route_check 8080 "-u ${AUTH_USER}:${AUTH_PASS}" 403 + [ "$status" -eq 0 ] } @test "authorized request: metrics enabled" { - run metrics_route_check 8080 "-u ${AUTH_USER}:${AUTH_PASS}" 200 + run metrics_route_check 8080 "-u ${METRICS_USER}:${METRICS_PASS}" 200 [ "$status" -eq 0 ] -} \ No newline at end of file +}