mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-28 13:24:13 +01:00
Merge pull request '[v8.0/forgejo] [SEC] Ensure propagation of API scopes for Conan and Container authentication' (#5151) from bp-v8.0/forgejo-5a871f6 into v8.0/forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5151 Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
commit
9969870cf5
8 changed files with 151 additions and 12 deletions
1
release-notes/5149.md
Normal file
1
release-notes/5149.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
The scope of application tokens is not verified when writing containers or Conan packages. This is of no consequence when the user associated with the application token does not have write access to packages. If the user has write access to packages, such a token can be used to write containers and Conan packages.
|
|
@ -22,7 +22,7 @@ func (a *Auth) Name() string {
|
||||||
|
|
||||||
// Verify extracts the user from the Bearer token
|
// Verify extracts the user from the Bearer token
|
||||||
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
||||||
uid, err := packages.ParseAuthorizationToken(req)
|
uid, scope, err := packages.ParseAuthorizationToken(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace("ParseAuthorizationToken: %v", err)
|
log.Trace("ParseAuthorizationToken: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -32,6 +32,12 @@ func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataS
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate scope of the authorization token.
|
||||||
|
if scope != "" {
|
||||||
|
store.GetData()["IsApiToken"] = true
|
||||||
|
store.GetData()["ApiTokenScope"] = scope
|
||||||
|
}
|
||||||
|
|
||||||
u, err := user_model.GetUserByID(req.Context(), uid)
|
u, err := user_model.GetUserByID(req.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetUserByID: %v", err)
|
log.Error("GetUserByID: %v", err)
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
packages_model "code.gitea.io/gitea/models/packages"
|
packages_model "code.gitea.io/gitea/models/packages"
|
||||||
conan_model "code.gitea.io/gitea/models/packages/conan"
|
conan_model "code.gitea.io/gitea/models/packages/conan"
|
||||||
|
@ -117,7 +118,10 @@ func Authenticate(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := packages_service.CreateAuthorizationToken(ctx.Doer)
|
// If there's an API scope, ensure it propagates.
|
||||||
|
scope, _ := ctx.Data.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope)
|
||||||
|
|
||||||
|
token, err := packages_service.CreateAuthorizationToken(ctx.Doer, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apiError(ctx, http.StatusInternalServerError, err)
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
|
|
|
@ -23,7 +23,7 @@ func (a *Auth) Name() string {
|
||||||
// Verify extracts the user from the Bearer token
|
// Verify extracts the user from the Bearer token
|
||||||
// If it's an anonymous session a ghost user is returned
|
// If it's an anonymous session a ghost user is returned
|
||||||
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
||||||
uid, err := packages.ParseAuthorizationToken(req)
|
uid, scope, err := packages.ParseAuthorizationToken(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace("ParseAuthorizationToken: %v", err)
|
log.Trace("ParseAuthorizationToken: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -33,6 +33,12 @@ func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataS
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate scope of the authorization token.
|
||||||
|
if scope != "" {
|
||||||
|
store.GetData()["IsApiToken"] = true
|
||||||
|
store.GetData()["ApiTokenScope"] = scope
|
||||||
|
}
|
||||||
|
|
||||||
u, err := user_model.GetPossibleUserByID(req.Context(), uid)
|
u, err := user_model.GetPossibleUserByID(req.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetPossibleUserByID: %v", err)
|
log.Error("GetPossibleUserByID: %v", err)
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
packages_model "code.gitea.io/gitea/models/packages"
|
packages_model "code.gitea.io/gitea/models/packages"
|
||||||
container_model "code.gitea.io/gitea/models/packages/container"
|
container_model "code.gitea.io/gitea/models/packages/container"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
@ -154,7 +155,10 @@ func Authenticate(ctx *context.Context) {
|
||||||
u = user_model.NewGhostUser()
|
u = user_model.NewGhostUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := packages_service.CreateAuthorizationToken(u)
|
// If there's an API scope, ensure it propagates.
|
||||||
|
scope, _ := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
|
||||||
|
|
||||||
|
token, err := packages_service.CreateAuthorizationToken(u, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apiError(ctx, http.StatusInternalServerError, err)
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
@ -19,9 +20,10 @@ import (
|
||||||
type packageClaims struct {
|
type packageClaims struct {
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
UserID int64
|
UserID int64
|
||||||
|
Scope auth_model.AccessTokenScope
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateAuthorizationToken(u *user_model.User) (string, error) {
|
func CreateAuthorizationToken(u *user_model.User, scope auth_model.AccessTokenScope) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
claims := packageClaims{
|
claims := packageClaims{
|
||||||
|
@ -30,6 +32,7 @@ func CreateAuthorizationToken(u *user_model.User) (string, error) {
|
||||||
NotBefore: jwt.NewNumericDate(now),
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
},
|
},
|
||||||
UserID: u.ID,
|
UserID: u.ID,
|
||||||
|
Scope: scope,
|
||||||
}
|
}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
@ -41,16 +44,16 @@ func CreateAuthorizationToken(u *user_model.User) (string, error) {
|
||||||
return tokenString, nil
|
return tokenString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseAuthorizationToken(req *http.Request) (int64, error) {
|
func ParseAuthorizationToken(req *http.Request) (int64, auth_model.AccessTokenScope, error) {
|
||||||
h := req.Header.Get("Authorization")
|
h := req.Header.Get("Authorization")
|
||||||
if h == "" {
|
if h == "" {
|
||||||
return 0, nil
|
return 0, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(h, " ", 2)
|
parts := strings.SplitN(h, " ", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
log.Error("split token failed: %s", h)
|
log.Error("split token failed: %s", h)
|
||||||
return 0, fmt.Errorf("split token failed")
|
return 0, "", fmt.Errorf("split token failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := jwt.ParseWithClaims(parts[1], &packageClaims{}, func(t *jwt.Token) (any, error) {
|
token, err := jwt.ParseWithClaims(parts[1], &packageClaims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
@ -60,13 +63,13 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
|
||||||
return setting.GetGeneralTokenSigningSecret(), nil
|
return setting.GetGeneralTokenSigningSecret(), nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
c, ok := token.Claims.(*packageClaims)
|
c, ok := token.Claims.(*packageClaims)
|
||||||
if !token.Valid || !ok {
|
if !token.Valid || !ok {
|
||||||
return 0, fmt.Errorf("invalid token claim")
|
return 0, "", fmt.Errorf("invalid token claim")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.UserID, nil
|
return c.UserID, c.Scope, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/models/packages"
|
"code.gitea.io/gitea/models/packages"
|
||||||
conan_model "code.gitea.io/gitea/models/packages/conan"
|
conan_model "code.gitea.io/gitea/models/packages/conan"
|
||||||
|
@ -224,6 +225,45 @@ func TestPackageConan(t *testing.T) {
|
||||||
assert.Equal(t, "revisions", resp.Header().Get("X-Conan-Server-Capabilities"))
|
assert.Equal(t, "revisions", resp.Header().Get("X-Conan-Server-Capabilities"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Token Scope Authentication", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
session := loginUser(t, user.Name)
|
||||||
|
|
||||||
|
testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token := getTokenForLoggedInUser(t, session, scope)
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/users/authenticate", url)).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
body := resp.Body.String()
|
||||||
|
assert.NotEmpty(t, body)
|
||||||
|
|
||||||
|
recipeURL := fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", url, "TestScope", version1, "testing", channel1)
|
||||||
|
|
||||||
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/upload_urls", recipeURL), map[string]int64{
|
||||||
|
conanfileName: 64,
|
||||||
|
"removed.txt": 0,
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, expectedStatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Read permission", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Write permission", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusOK)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
token := ""
|
token := ""
|
||||||
|
|
||||||
t.Run("Authenticate", func(t *testing.T) {
|
t.Run("Authenticate", func(t *testing.T) {
|
||||||
|
@ -481,6 +521,43 @@ func TestPackageConan(t *testing.T) {
|
||||||
|
|
||||||
token := ""
|
token := ""
|
||||||
|
|
||||||
|
t.Run("Token Scope Authentication", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
session := loginUser(t, user.Name)
|
||||||
|
|
||||||
|
testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token := getTokenForLoggedInUser(t, session, scope)
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/authenticate", url)).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
body := resp.Body.String()
|
||||||
|
assert.NotEmpty(t, body)
|
||||||
|
|
||||||
|
recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", url, "TestScope", version1, "testing", channel1, revision1)
|
||||||
|
|
||||||
|
req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", recipeURL, conanfileName), strings.NewReader("Doesn't need to be valid")).
|
||||||
|
AddTokenAuth("Bearer " + body)
|
||||||
|
MakeRequest(t, req, expectedStatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Read permission", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Write permission", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusCreated)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Authenticate", func(t *testing.T) {
|
t.Run("Authenticate", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
@ -512,7 +589,7 @@ func TestPackageConan(t *testing.T) {
|
||||||
|
|
||||||
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConan)
|
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConan)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, pvs, 2)
|
assert.Len(t, pvs, 3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -78,6 +78,7 @@ func TestPackageContainer(t *testing.T) {
|
||||||
indexManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageIndex + `","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"` + manifestDigest + `","platform":{"os":"linux","architecture":"arm","variant":"v7"}},{"mediaType":"` + oci.MediaTypeImageManifest + `","digest":"` + untaggedManifestDigest + `","platform":{"os":"linux","architecture":"arm64","variant":"v8"}}]}`
|
indexManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageIndex + `","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"` + manifestDigest + `","platform":{"os":"linux","architecture":"arm","variant":"v7"}},{"mediaType":"` + oci.MediaTypeImageManifest + `","digest":"` + untaggedManifestDigest + `","platform":{"os":"linux","architecture":"arm64","variant":"v8"}}]}`
|
||||||
|
|
||||||
anonymousToken := ""
|
anonymousToken := ""
|
||||||
|
readUserToken := ""
|
||||||
userToken := ""
|
userToken := ""
|
||||||
|
|
||||||
t.Run("Authenticate", func(t *testing.T) {
|
t.Run("Authenticate", func(t *testing.T) {
|
||||||
|
@ -140,6 +141,30 @@ func TestPackageContainer(t *testing.T) {
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
|
req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
|
||||||
AddTokenAuth(userToken)
|
AddTokenAuth(userToken)
|
||||||
MakeRequest(t, req, http.StatusOK)
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// Token that should enforce the read scope.
|
||||||
|
t.Run("Read scope", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
session := loginUser(t, user.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage)
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL))
|
||||||
|
req.SetBasicAuth(user.Name, token)
|
||||||
|
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
tokenResponse := &TokenResponse{}
|
||||||
|
DecodeJSON(t, resp, &tokenResponse)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, tokenResponse.Token)
|
||||||
|
|
||||||
|
readUserToken = fmt.Sprintf("Bearer %s", tokenResponse.Token)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
|
||||||
|
AddTokenAuth(readUserToken)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -163,6 +188,10 @@ func TestPackageContainer(t *testing.T) {
|
||||||
AddTokenAuth(anonymousToken)
|
AddTokenAuth(anonymousToken)
|
||||||
MakeRequest(t, req, http.StatusUnauthorized)
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
|
||||||
|
req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)).
|
||||||
|
AddTokenAuth(readUserToken)
|
||||||
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
|
||||||
req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, unknownDigest), bytes.NewReader(blobContent)).
|
req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, unknownDigest), bytes.NewReader(blobContent)).
|
||||||
AddTokenAuth(userToken)
|
AddTokenAuth(userToken)
|
||||||
MakeRequest(t, req, http.StatusBadRequest)
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
@ -318,6 +347,11 @@ func TestPackageContainer(t *testing.T) {
|
||||||
SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
|
SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
|
||||||
MakeRequest(t, req, http.StatusUnauthorized)
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
|
||||||
|
req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
|
||||||
|
AddTokenAuth(readUserToken).
|
||||||
|
SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
|
||||||
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
|
||||||
req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
|
req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
|
||||||
AddTokenAuth(userToken).
|
AddTokenAuth(userToken).
|
||||||
SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
|
SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
|
||||||
|
@ -521,6 +555,10 @@ func TestPackageContainer(t *testing.T) {
|
||||||
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
|
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
|
||||||
AddTokenAuth(anonymousToken)
|
AddTokenAuth(anonymousToken)
|
||||||
MakeRequest(t, req, http.StatusOK)
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
|
||||||
|
AddTokenAuth(readUserToken)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetBlob", func(t *testing.T) {
|
t.Run("GetBlob", func(t *testing.T) {
|
||||||
|
|
Loading…
Reference in a new issue