Files
zot/pkg/api/routes_test.go
T
Ramkumar Chinchani 55b68228da feat(storage): redirect blob pulls to backend URLs (#4092)
* feat(storage): redirect blob pulls to backend URLs

* fix: rebase conflicts

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* refactor: rename redirect field

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test: relax brittle TestPeriodicGC substore log assertion

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* feat(storage): improve blob redirect config handling and validation

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): address PR review feedback for blob redirect

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* feat(storage): apply latest PR review fixes for blob redirect

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test: fix blob redirect and verify test regressions

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): enforce redirectBlobURL validation and add redirect tests

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): fix err113/noctx lint errors in storage driver tests

- Replace httptest.NewRequest with httptest.NewRequestWithContext in
  s3, gcs, and imagestore driver tests (noctx)
- Replace dynamic errors.New in s3 driver test with a package-level
  static sentinel error (err113)

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test(storage): use temp dirs in imagestore redirect tests

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix: handle ranged blob redirects and add regression tests

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix: validate blob digest consistently in GetBlob

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test: fix GetBlobPartialFn mock return values for range requests

The test 'does not redirect ranged blob requests' was failing because the mock
was returning incorrect length values. For a range request 'bytes=0-0' (1 byte),
it was returning 4 bytes, which caused a length mismatch check in GetBlob to
return HTTP 500.

Fix the mock to dynamically calculate the correct length: to - from + 1

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): preserve signed URL bytes in normalizeBlobRedirectURL

Preserve the original URL bytes from backend storage drivers (important
for signed/presigned URLs) while only lowercasing the scheme prefix.
URL re-serialization via net/url can invalidate signatures through path
escaping or canonicalization.

Add regression tests covering signed URL query parameters and mixed-case
scheme handling.

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): address PR review comments for blob redirect

- Return signed redirect URLs unchanged; validate scheme/CRLF/host only,
  no URL normalization that would corrupt signed URL bytes
- Add inline comments for all non-obvious decisions: range bypass, soft
  fallback on invalid URL, local driver empty return, subpath resolution,
  redirectBlobURL config constraint on local/empty driver
- Expand TestNormalizeBlobRedirectURL to cover allowed schemes (http/https),
  parse failure, missing host, and CRLF injection cases
- Add TestIsBlobRedirectEnabled covering subpath-only enablement with
  default store disabled

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test(storage): address remaining blob redirect review comments

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix: gofumpt formatting in routes_test.go

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

---------

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>
Co-authored-by: Akash Kumar <meakash7902@gmail.com>
2026-06-15 14:36:07 -07:00

3501 lines
103 KiB
Go

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