0
Fork 0
mirror of https://github.com/project-zot/zot.git synced 2024-12-16 21:56:37 -05:00

graphql: Apply authorization on /_search endpoint

- AccessControlContext now resides in a separate package from where it can be imported,
along with the contextKey that will be used to set and retrieve this context value.

- AccessControlContext has a new field called Username, that will be of use for future
implementations in graphQL resolvers.

- GlobalSearch resolver now uses this context to filter repos available to the logged user.

- moved logic for uploading images in tests so that it can be used in every package

- tests were added for multiple request scenarios, when zot-server requires authz
on specific repos

- added tests with injected errors for extended coverage

- added tests for status code error injection utilities

Closes https://github.com/project-zot/zot/issues/615

Signed-off-by: Alex Stan <alexandrustan96@yahoo.ro>
This commit is contained in:
Alex Stan 2022-08-16 11:57:09 +03:00 committed by Andrei Aaron
parent 5450139ba1
commit 49e8167dbe
15 changed files with 763 additions and 165 deletions

View file

@ -8,8 +8,8 @@
"port": "8080",
"realm": "zot",
"tls": {
"cert": "test/data/server.cert",
"key": "test/data/server.key"
"cert": "../../test/data/server.cert",
"key": "../../test/data/server.key"
}
},
"log": {

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"time"
glob "github.com/bmatcuk/doublestar/v4"
@ -12,19 +13,15 @@ import (
"zotregistry.io/zot/pkg/api/constants"
"zotregistry.io/zot/pkg/common"
"zotregistry.io/zot/pkg/log"
localCtx "zotregistry.io/zot/pkg/requestcontext"
)
type contextKey int
const (
// actions.
CREATE = "create"
READ = "read"
UPDATE = "update"
DELETE = "delete"
// request-local context key.
authzCtxKey contextKey = 0
)
// AccessController authorizes users to act on resources.
@ -33,12 +30,6 @@ type AccessController struct {
Log log.Logger
}
// AccessControlContext context passed down to http.Handlers.
type AccessControlContext struct {
globPatterns map[string]bool
isAdmin bool
}
func NewAccessController(config *config.Config) *AccessController {
return &AccessController{
Config: config.AccessControl,
@ -111,14 +102,18 @@ func (ac *AccessController) isAdmin(username string) bool {
// getContext builds ac context(allowed to read repos and if user is admin) and returns it.
func (ac *AccessController) getContext(username string, request *http.Request) context.Context {
readGlobPatterns := ac.getReadGlobPatterns(username)
acCtx := AccessControlContext{globPatterns: readGlobPatterns}
if ac.isAdmin(username) {
acCtx.isAdmin = true
} else {
acCtx.isAdmin = false
acCtx := localCtx.AccessControlContext{
GlobPatterns: readGlobPatterns,
Username: username,
}
if ac.isAdmin(username) {
acCtx.IsAdmin = true
} else {
acCtx.IsAdmin = false
}
authzCtxKey := localCtx.GetContextKey()
ctx := context.WithValue(request.Context(), authzCtxKey, acCtx)
return ctx
@ -227,6 +222,12 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
return
}
if strings.Contains(request.RequestURI, constants.ExtSearchPrefix) {
next.ServeHTTP(response, request.WithContext(ctx))
return
}
var action string
if request.Method == http.MethodGet || request.Method == http.MethodHead {
action = READ

View file

@ -317,7 +317,6 @@ func TestHtpasswdTwoCreds(t *testing.T) {
}
ctlr := api.NewController(conf)
ctlr.Config.Storage.RootDirectory = t.TempDir()
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
@ -5701,7 +5700,7 @@ func TestInjectTooManyOpenFiles(t *testing.T) {
func TestPeriodicGC(t *testing.T) {
Convey("Periodic gc enabled for default store", t, func() {
repoName := "test"
repoName := "testRepo"
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
@ -5858,6 +5857,163 @@ func TestPeriodicTasks(t *testing.T) {
})
}
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 := api.NewController(conf)
ctlr.Config.Storage.RootDirectory = tempDir
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
repoName := "testrepo"
inaccessibleRepo := "inaccessible"
cfg, layers, manifest, err := test.GetImageComponents(10000)
So(err, ShouldBeNil)
err = test.UploadImage(
test.Image{
Config: cfg,
Layers: layers,
Manifest: manifest,
Tag: "latest",
}, baseURL, repoName)
So(err, ShouldBeNil)
// data for the inaccessible repo
cfg, layers, manifest, err = test.GetImageComponents(10000)
So(err, ShouldBeNil)
err = test.UploadImage(
test.Image{
Config: cfg,
Layers: layers,
Manifest: manifest,
Tag: "latest",
}, baseURL, inaccessibleRepo)
So(err, ShouldBeNil)
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,
},
}
conf.HTTP.Port = port
defaultVal := true
searchConfig := &extconf.SearchConfig{
Enable: &defaultVal,
}
conf.Extensions = &extconf.ExtensionConfig{
Search: searchConfig,
}
conf.AccessControl = &config.AccessControlConfig{
Repositories: config.Repositories{
repoName: config.PolicyGroup{
Policies: []config.Policy{
{
Users: []string{user1},
Actions: []string{"read"},
},
},
DefaultPolicy: []string{},
},
inaccessibleRepo: config.PolicyGroup{
Policies: []config.Policy{
{
Users: []string{},
Actions: []string{},
},
},
DefaultPolicy: []string{},
},
},
AdminPolicy: config.Policy{
Users: []string{},
Actions: []string{},
},
}
ctlr := api.NewController(conf)
ctlr.Config.Storage.RootDirectory = tempDir
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
query := `
{
GlobalSearch(query:""){
Repos {
Name
Score
NewestImage {
RepoName
Tag
}
}
}
}`
resp, err := resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.ExtSearchPrefix +
"?query=" + url.QueryEscape(query))
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, 200)
So(string(resp.Body()), ShouldContainSubstring, repoName)
So(string(resp.Body()), ShouldNotContainSubstring, inaccessibleRepo)
resp, err = resty.R().Get(baseURL + constants.ExtSearchPrefix + "?query=" + url.QueryEscape(query))
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
// credentials for user unauthorized to access repo
user2 := "notWorking"
password2 := "notWorking"
testString2 := getCredString(user2, password2)
htpasswdPath2 := test.MakeHtpasswdFileFromString(testString2)
defer os.Remove(htpasswdPath2)
ctlr.Config.HTTP.Auth = &config.AuthConfig{
HTPasswd: config.AuthHTPasswd{
Path: htpasswdPath2,
},
}
// authenticated, but no access to resource
resp, err = resty.R().SetBasicAuth(user2, password2).Get(baseURL + constants.ExtSearchPrefix +
"?query=" + url.QueryEscape(query))
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
})
})
}
func TestDistSpecExtensions(t *testing.T) {
Convey("start zot server with search extension", t, func(c C) {
conf := config.New()

View file

@ -34,6 +34,7 @@ import (
"zotregistry.io/zot/pkg/api/constants"
ext "zotregistry.io/zot/pkg/extensions"
"zotregistry.io/zot/pkg/log"
localCtx "zotregistry.io/zot/pkg/requestcontext"
"zotregistry.io/zot/pkg/storage"
"zotregistry.io/zot/pkg/test" // nolint:goimports
// as required by swaggo.
@ -1240,9 +1241,11 @@ func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request *
}
var repos []string
authzCtxKey := localCtx.GetContextKey()
// get passed context from authzHandler and filter out repos based on permissions
if authCtx := request.Context().Value(authzCtxKey); authCtx != nil {
acCtx, ok := authCtx.(AccessControlContext)
acCtx, ok := authCtx.(localCtx.AccessControlContext)
if !ok {
response.WriteHeader(http.StatusInternalServerError)
@ -1250,7 +1253,7 @@ func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request *
}
for _, r := range combineRepoList {
if acCtx.isAdmin || matchesRepo(acCtx.globPatterns, r) {
if acCtx.IsAdmin || matchesRepo(acCtx.GlobPatterns, r) {
repos = append(repos, r)
}
}

View file

@ -71,8 +71,8 @@ func SetupSearchRoutes(config *config.Config, router *mux.Router, storeControlle
resConfig = search.GetResolverConfig(log, storeController, false)
}
router.PathPrefix(constants.ExtSearchPrefix).Methods("OPTIONS", "GET", "POST").
Handler(gqlHandler.NewDefaultServer(gql_generated.NewExecutableSchema(resConfig)))
graphqlPrefix := router.PathPrefix(constants.ExtSearchPrefix).Methods("OPTIONS", "GET", "POST")
graphqlPrefix.Handler(gqlHandler.NewDefaultServer(gql_generated.NewExecutableSchema(resConfig)))
}
}

View file

@ -9,7 +9,6 @@ import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"os/exec"
@ -19,7 +18,6 @@ import (
"time"
"github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/specs-go"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sigstore/cosign/cmd/cosign/cli/generate"
"github.com/sigstore/cosign/cmd/cosign/cli/options"
@ -44,8 +42,6 @@ const (
var (
ErrTestError = errors.New("test error")
ErrPutBlob = errors.New("can't put blob")
ErrPostBlob = errors.New("can't post blob")
ErrPutManifest = errors.New("can't put manifest")
)
@ -1043,7 +1039,7 @@ func TestSearchSize(t *testing.T) {
WaitTillServerReady(baseURL)
repoName := "testrepo"
config, layers, manifest, err := getImageComponents(10000)
config, layers, manifest, err := GetImageComponents(10000)
So(err, ShouldBeNil)
configBlob, err := json.Marshal(config)
@ -1060,7 +1056,7 @@ func TestSearchSize(t *testing.T) {
manifestSize := len(manifestBlob)
err = UploadImage(
uploadImage{
Image{
Manifest: manifest,
Config: config,
Layers: layers,
@ -1107,7 +1103,7 @@ func TestSearchSize(t *testing.T) {
// add the same image with different tag
err = UploadImage(
uploadImage{
Image{
Manifest: manifest,
Config: config,
Layers: layers,
@ -1135,136 +1131,6 @@ func TestSearchSize(t *testing.T) {
})
}
func getImageComponents(layerSize int) (ispec.Image, [][]byte, ispec.Manifest, error) {
config := ispec.Image{
Architecture: "amd64",
OS: "linux",
RootFS: ispec.RootFS{
Type: "layers",
DiffIDs: []digest.Digest{},
},
Author: "ZotUser",
}
configBlob, err := json.Marshal(config)
if err != nil {
return ispec.Image{}, [][]byte{}, ispec.Manifest{}, err
}
configDigest := digest.FromBytes(configBlob)
layers := [][]byte{
make([]byte, layerSize),
}
manifest := ispec.Manifest{
Versioned: specs.Versioned{
SchemaVersion: 2,
},
Config: ispec.Descriptor{
MediaType: "application/vnd.oci.image.config.v1+json",
Digest: configDigest,
Size: int64(len(configBlob)),
},
Layers: []ispec.Descriptor{
{
MediaType: "application/vnd.oci.image.layer.v1.tar",
Digest: digest.FromBytes(layers[0]),
Size: int64(len(layers[0])),
},
},
}
return config, layers, manifest, nil
}
type uploadImage struct {
Manifest ispec.Manifest
Config ispec.Image
Layers [][]byte
Tag string
}
func UploadImage(img uploadImage, baseURL, repo string) error {
for _, blob := range img.Layers {
resp, err := resty.R().Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
if err != nil {
return err
}
if resp.StatusCode() != http.StatusAccepted {
return ErrPostBlob
}
loc := resp.Header().Get("Location")
digest := digest.FromBytes(blob).String()
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)
if resp.StatusCode() != http.StatusCreated {
return ErrPutBlob
}
if err != nil {
return err
}
}
// upload config
cblob, err := json.Marshal(img.Config)
if err != nil {
return err
}
cdigest := digest.FromBytes(cblob)
resp, err := resty.R().
Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
if err != nil {
return err
}
if resp.StatusCode() != http.StatusAccepted {
return ErrPostBlob
}
loc := 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)
if err != nil {
return err
}
if resp.StatusCode() != http.StatusCreated {
return ErrPutBlob
}
// put manifest
manifestBlob, err := json.Marshal(img.Manifest)
if err != nil {
return err
}
_, err = resty.R().
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
SetBody(manifestBlob).
Put(baseURL + "/v2/" + repo + "/manifests/" + img.Tag)
return err
}
func startServer(c *api.Controller) {
// this blocks
ctx := context.Background()

View file

@ -5,19 +5,22 @@ package search
// It serves as dependency injection for your app, add any dependencies you require here.
import (
"context"
"errors"
"sort"
"strconv"
"strings"
glob "github.com/bmatcuk/doublestar/v4"
v1 "github.com/google/go-containerregistry/pkg/v1"
godigest "github.com/opencontainers/go-digest"
"zotregistry.io/zot/pkg/log" // nolint: gci
ispec "github.com/opencontainers/image-spec/specs-go/v1"
"zotregistry.io/zot/pkg/extensions/search/common"
cveinfo "zotregistry.io/zot/pkg/extensions/search/cve"
digestinfo "zotregistry.io/zot/pkg/extensions/search/digest"
"zotregistry.io/zot/pkg/extensions/search/gql_generated"
"zotregistry.io/zot/pkg/log" // nolint: gci
localCtx "zotregistry.io/zot/pkg/requestcontext"
"zotregistry.io/zot/pkg/storage"
) // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
@ -36,6 +39,8 @@ type cveDetail struct {
PackageList []*gql_generated.PackageInfo
}
var ErrBadCtxFormat = errors.New("type assertion failed")
// GetResolverConfig ...
func GetResolverConfig(log log.Logger, storeController storage.StoreController, enableCVE bool) gql_generated.Config {
var cveInfo *cveinfo.CveInfo
@ -469,3 +474,47 @@ func buildImageInfo(repo string, tag string, tagDigest godigest.Digest,
return imageInfo
}
// returns either a user has or not rights on 'repository'.
func matchesRepo(globPatterns map[string]bool, repository string) bool {
var longestMatchedPattern string
// because of the longest path matching rule, we need to check all patterns from config
for pattern := range globPatterns {
matched, err := glob.Match(pattern, repository)
if err == nil {
if matched && len(pattern) > len(longestMatchedPattern) {
longestMatchedPattern = pattern
}
}
}
allowed := globPatterns[longestMatchedPattern]
return allowed
}
// get passed context from authzHandler and filter out repos based on permissions.
func userAvailableRepos(ctx context.Context, repoList []string) ([]string, error) {
var availableRepos []string
authzCtxKey := localCtx.GetContextKey()
if authCtx := ctx.Value(authzCtxKey); authCtx != nil {
acCtx, ok := authCtx.(localCtx.AccessControlContext)
if !ok {
err := ErrBadCtxFormat
return []string{}, err
}
for _, r := range repoList {
if acCtx.IsAdmin || matchesRepo(acCtx.GlobPatterns, r) {
availableRepos = append(availableRepos, r)
}
}
} else {
availableRepos = repoList
}
return availableRepos, nil
}

View file

@ -1,16 +1,22 @@
package search //nolint
import (
"context"
"errors"
"os"
"strings"
"testing"
v1 "github.com/google/go-containerregistry/pkg/v1"
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/rs/zerolog"
. "github.com/smartystreets/goconvey/convey"
"zotregistry.io/zot/pkg/extensions/monitoring"
"zotregistry.io/zot/pkg/extensions/search/common"
"zotregistry.io/zot/pkg/log"
localCtx "zotregistry.io/zot/pkg/requestcontext"
"zotregistry.io/zot/pkg/storage"
"zotregistry.io/zot/pkg/test/mocks"
)
@ -172,6 +178,28 @@ func TestGlobalSearch(t *testing.T) {
})
}
func TestUserAvailableRepos(t *testing.T) {
Convey("Type assertion fails", t, func() {
var invalid struct{}
log := log.Logger{Logger: zerolog.New(os.Stdout)}
dir := t.TempDir()
metrics := monitoring.NewMetricsServer(false, log)
defaultStore := storage.NewImageStore(dir, false, 0, false, false, log, metrics, nil)
repoList, err := defaultStore.GetRepositories()
So(err, ShouldBeNil)
ctx := context.TODO()
key := localCtx.GetContextKey()
ctx = context.WithValue(ctx, key, invalid)
repos, err := userAvailableRepos(ctx, repoList)
So(err, ShouldNotBeNil)
So(repos, ShouldBeEmpty)
})
}
func TestMatching(t *testing.T) {
pine := "pine"

View file

@ -454,7 +454,14 @@ func (r *queryResolver) GlobalSearch(ctx context.Context, query string) (*gql_ge
return &gql_generated.GlobalSearchResult{}, err
}
repos, images, layers := globalSearch(repoList, name, tag, olu, r.log)
availableRepos, err := userAvailableRepos(ctx, repoList)
if err != nil {
r.log.Error().Err(err).Msg("unable to filter user available repositories")
return &gql_generated.GlobalSearchResult{}, err
}
repos, images, layers := globalSearch(availableRepos, name, tag, olu, r.log)
return &gql_generated.GlobalSearchResult{
Images: images,

View file

@ -0,0 +1,18 @@
package requestcontext
type Key int
// request-local context key.
var authzCtxKey = Key(0) // nolint: gochecknoglobals
// pointer needed for use in context.WithValue.
func GetContextKey() *Key {
return &authzCtxKey
}
// AccessControlContext context passed down to http.Handlers.
type AccessControlContext struct {
GlobPatterns map[string]bool
IsAdmin bool
Username string
}

View file

@ -4,17 +4,20 @@ import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"math/big"
"net/http"
"net/url"
"os"
"path"
"time"
godigest "github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/specs-go"
imagespec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/opencontainers/umoci"
"github.com/phayes/freeport"
@ -27,6 +30,18 @@ const (
SleepTime = 100 * time.Millisecond
)
var (
ErrPostBlob = errors.New("can't post blob")
ErrPutBlob = errors.New("can't put blob")
)
type Image struct {
Manifest imagespec.Manifest
Config imagespec.Image
Layers [][]byte
Tag string
}
func GetFreePort() string {
port, err := freeport.GetFreePort()
if err != nil {
@ -264,3 +279,127 @@ func GetOciLayoutDigests(imagePath string) (godigest.Digest, godigest.Digest, go
return manifestDigest, configDigest, layerDigest
}
func GetImageComponents(layerSize int) (imagespec.Image, [][]byte, imagespec.Manifest, error) {
config := imagespec.Image{
Architecture: "amd64",
OS: "linux",
RootFS: imagespec.RootFS{
Type: "layers",
DiffIDs: []godigest.Digest{},
},
Author: "ZotUser",
}
configBlob, err := json.Marshal(config)
if err = Error(err); err != nil {
return imagespec.Image{}, [][]byte{}, imagespec.Manifest{}, err
}
configDigest := godigest.FromBytes(configBlob)
layers := [][]byte{
make([]byte, layerSize),
}
schemaVersion := 2
manifest := imagespec.Manifest{
Versioned: specs.Versioned{
SchemaVersion: schemaVersion,
},
Config: imagespec.Descriptor{
MediaType: "application/vnd.oci.image.config.v1+json",
Digest: configDigest,
Size: int64(len(configBlob)),
},
Layers: []imagespec.Descriptor{
{
MediaType: "application/vnd.oci.image.layer.v1.tar",
Digest: godigest.FromBytes(layers[0]),
Size: int64(len(layers[0])),
},
},
}
return config, layers, manifest, nil
}
func UploadImage(img Image, baseURL, repo string) error {
for _, blob := range img.Layers {
resp, err := resty.R().Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
if err != nil {
return err
}
if resp.StatusCode() != http.StatusAccepted {
return ErrPostBlob
}
loc := resp.Header().Get("Location")
digest := godigest.FromBytes(blob).String()
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)
if err != nil {
return err
}
if resp.StatusCode() != http.StatusCreated {
return ErrPutBlob
}
}
// upload config
cblob, err := json.Marshal(img.Config)
if err = Error(err); err != nil {
return err
}
cdigest := godigest.FromBytes(cblob)
resp, err := resty.R().
Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
if err = Error(err); err != nil {
return err
}
if ErrStatusCode(resp.StatusCode()) != http.StatusAccepted && ErrStatusCode(resp.StatusCode()) == -1 {
return ErrPostBlob
}
loc := 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)
if err = Error(err); err != nil {
return err
}
if ErrStatusCode(resp.StatusCode()) != http.StatusCreated && ErrStatusCode(resp.StatusCode()) == -1 {
return ErrPostBlob
}
// put manifest
manifestBlob, err := json.Marshal(img.Manifest)
if err = Error(err); err != nil {
return err
}
_, err = resty.R().
SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json").
SetBody(manifestBlob).
Put(baseURL + "/v2/" + repo + "/manifests/" + img.Tag)
return err
}

View file

@ -4,14 +4,18 @@
package test_test
import (
"context"
"encoding/json"
"io/ioutil"
"os"
"path"
"testing"
"github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
. "github.com/smartystreets/goconvey/convey"
"zotregistry.io/zot/pkg/api"
"zotregistry.io/zot/pkg/api/config"
"zotregistry.io/zot/pkg/test"
)
@ -122,3 +126,283 @@ func TestGetOciLayoutDigests(t *testing.T) {
}
})
}
func TestGetImageComponents(t *testing.T) {
Convey("Inject failures for unreachable lines", t, func() {
injected := test.InjectFailure(0)
if injected {
_, _, _, err := test.GetImageComponents(100)
So(err, ShouldNotBeNil)
}
})
Convey("finishes successfully", t, func() {
_, _, _, err := test.GetImageComponents(100)
So(err, ShouldBeNil)
})
}
func TestUploadImage(t *testing.T) {
Convey("Post request results in an error", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = t.TempDir()
img := test.Image{
Layers: make([][]byte, 10),
}
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
})
Convey("Post request status differs from accepted", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
tempDir := t.TempDir()
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = tempDir
err := os.Chmod(tempDir, 0o400)
if err != nil {
t.Fatal(err)
}
ctlr := api.NewController(conf)
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
img := test.Image{
Layers: make([][]byte, 10),
}
err = test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
})
Convey("Put request results in an error", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = t.TempDir()
ctlr := api.NewController(conf)
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
img := test.Image{
Layers: make([][]byte, 10), // invalid format that will result in an error
Config: ispec.Image{},
}
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
})
Convey("Image uploaded successfully", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = t.TempDir()
ctlr := api.NewController(conf)
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
layerBlob := []byte("test")
img := test.Image{
Layers: [][]byte{
layerBlob,
}, // invalid format that will result in an error
Config: ispec.Image{},
}
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldBeNil)
})
Convey("Blob upload wrong response status code", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
tempDir := t.TempDir()
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = tempDir
ctlr := api.NewController(conf)
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
layerBlob := []byte("test")
layerBlobDigest := digest.FromBytes(layerBlob)
layerPath := path.Join(tempDir, "test", "blobs", "sha256")
if _, err := os.Stat(layerPath); os.IsNotExist(err) {
err = os.MkdirAll(layerPath, 0o700)
if err != nil {
t.Fatal(err)
}
file, err := os.Create(path.Join(layerPath, layerBlobDigest.Encoded()))
if err != nil {
t.Fatal(err)
}
err = os.Chmod(layerPath, 0o000)
if err != nil {
t.Fatal(err)
}
defer func() {
err = os.Chmod(layerPath, 0o700)
if err != nil {
t.Fatal(err)
}
os.RemoveAll(file.Name())
}()
}
img := test.Image{
Layers: [][]byte{
layerBlob,
}, // invalid format that will result in an error
Config: ispec.Image{},
}
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
})
Convey("CreateBlobUpload wrong response status code", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
tempDir := t.TempDir()
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = tempDir
ctlr := api.NewController(conf)
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
layerBlob := []byte("test")
img := test.Image{
Layers: [][]byte{
layerBlob,
}, // invalid format that will result in an error
Config: ispec.Image{},
}
Convey("CreateBlobUpload", func() {
injected := test.InjectFailure(2)
if injected {
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
}
})
Convey("UpdateBlobUpload", func() {
injected := test.InjectFailure(4)
if injected {
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
}
})
})
}
func TestInjectUploadImage(t *testing.T) {
Convey("Inject failures for unreachable lines", t, func() {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
tempDir := t.TempDir()
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = tempDir
ctlr := api.NewController(conf)
go startServer(ctlr)
defer stopServer(ctlr)
test.WaitTillServerReady(baseURL)
layerBlob := []byte("test")
layerPath := path.Join(tempDir, "test", ".uploads")
if _, err := os.Stat(layerPath); os.IsNotExist(err) {
err = os.MkdirAll(layerPath, 0o700)
if err != nil {
t.Fatal(err)
}
}
img := test.Image{
Layers: [][]byte{
layerBlob,
}, // invalid format that will result in an error
Config: ispec.Image{},
}
Convey("first marshal", func() {
injected := test.InjectFailure(0)
if injected {
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
}
})
Convey("CreateBlobUpload POST call", func() {
injected := test.InjectFailure(1)
if injected {
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
}
})
Convey("UpdateBlobUpload PUT call", func() {
injected := test.InjectFailure(3)
if injected {
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
}
})
Convey("second marshal", func() {
injected := test.InjectFailure(5)
if injected {
err := test.UploadImage(img, baseURL, "test")
So(err, ShouldNotBeNil)
}
})
})
}
func startServer(c *api.Controller) {
// this blocks
ctx := context.Background()
if err := c.Run(ctx); err != nil {
return
}
}
func stopServer(c *api.Controller) {
ctx := context.Background()
_ = c.Server.Shutdown(ctx)
}

View file

@ -6,6 +6,7 @@
package test
import (
"net/http"
"sync"
zerr "zotregistry.io/zot/errors"
@ -36,6 +37,20 @@ func Error(err error) error {
return nil
}
// Used to inject error status codes for coverage purposes.
// -1 will be returned in case of successful failure injection.
func ErrStatusCode(status int) int {
if !injectedFailure() {
if status == http.StatusAccepted || status == http.StatusCreated {
return status
}
return 0
}
return -1
}
/**
*
* Failure injection infrastructure to cover hard-to-reach code paths.

View file

@ -59,6 +59,18 @@ func bar() error {
return nil
}
func baz() error {
if test.ErrStatusCode(0) != 0 {
return errCall1
}
if test.ErrStatusCode(0) != 0 {
return errCall2
}
return nil
}
func alwaysErr() error {
return errNotZero
}
@ -108,6 +120,22 @@ func TestInject(t *testing.T) {
So(errors.Is(err, errCall2), ShouldBeTrue)
})
})
Convey("Check ErrStatusCode", func() {
Convey("Without skipping", func() {
test.InjectFailure(0) // inject a failure
err := baz() // should be a failure
So(err, ShouldNotBeNil) // should be a failure
So(errors.Is(err, errCall1), ShouldBeTrue)
})
Convey("With skipping", func() {
test.InjectFailure(1) // inject a failure but skip first one
err := baz() // should be a failure
So(errors.Is(err, errCall1), ShouldBeFalse)
So(errors.Is(err, errCall2), ShouldBeTrue)
})
})
})
Convey("Without injected failure", t, func(c C) {

View file

@ -11,6 +11,10 @@ func Ok(ok bool) bool {
return ok
}
func ErrStatusCode(statusCode int) int {
return statusCode
}
/**
*
* Failure injection infrastructure to cover hard-to-reach code paths (nop in production).