mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-06-22 10:02:15 +00:00
feat: enable auth to git LFS via authorized integrations (#12725)
The goal is to enable access to Git LFS resources with Authorized Integrations JWTs. Blocker that needed to be resolved is that adding the `AuthorizedIntegration` auth method would conflict with the LFS tokens, which are handed out during git ssh clones to allow access to LFS resources -- `AuthorizedIntegration` would mark these as `AuthenticationAttemptedIncorrectCredential`, and therefore the requests would 401 before they got to the LFS-specific token validation routines. The fix is to move LFS token authentication into an authentication group so that it could be resolved at the same time as the authorized integration, rather than doing it inside the LFS server routines. Refactors for LFS tokens are covered by refreshed test automation. Authorized integrations LFS Access has been manually tested, and will be further covered in an end-to-end integration test (https://code.forgejo.org/forgejo/end-to-end/pulls/1954). ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [ ] `make pr-go` before pushing ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12725 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
parent
c37f5a96a9
commit
cdd35458a9
18 changed files with 494 additions and 351 deletions
|
|
@ -23,13 +23,13 @@ import (
|
|||
"forgejo.org/models/perm"
|
||||
"forgejo.org/modules/git"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/pprof"
|
||||
"forgejo.org/modules/private"
|
||||
"forgejo.org/modules/process"
|
||||
repo_module "forgejo.org/modules/repository"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/lfs"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/kballard/go-shellquote"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import (
|
|||
"time"
|
||||
|
||||
"forgejo.org/modules/util"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -117,3 +119,11 @@ type ErrorResponse struct {
|
|||
DocumentationURL string `json:"documentation_url,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
// Claims is a JWT Token Claims
|
||||
type Claims struct {
|
||||
RepoID int64
|
||||
Op string
|
||||
UserID int64
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,11 +105,15 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
|
|||
// earlier authentication success would prevent later authentication methods from being attempted.
|
||||
func buildAuthGroup() *auth_method.Group {
|
||||
group := auth_method.NewGroup()
|
||||
group.Add(&auth_method.OAuth2{}) // FIXME: this should be removed and only applied in download and oauth related routers
|
||||
group.Add(&auth_method.Basic{}) // FIXME: this should be removed and only applied in download and git/lfs routers
|
||||
|
||||
// FIXME: extracted from OAuth2 & Basic -- these methods have internal URL filters that should be moved into
|
||||
// middlewares (if we can figure out the right way to do that), similar to the notes on OAuth2 & Basic above.
|
||||
// FIXME: OAuth2, Basic, AccessToken, ActionRuntimeToken, ActionTaskToken authentication methods all use path
|
||||
// matching so that they only authenticate requests in specific endpoints -- in general these auth methods aren't
|
||||
// permitted for web endpoints, and aren't supported because the security models don't match. (For example, they
|
||||
// can have scoped permissions, which web UI handlers don't implement.) A clearer way to do this would be to apply
|
||||
// specific authentication methods to their auth middlewares. buildGitLfsAuthGroup and buildGitAuthGroup are the
|
||||
// start of transitioning to that model. Eventually these methods will be fully removed from buildAuthGroup:
|
||||
group.Add(&auth_method.OAuth2{})
|
||||
group.Add(&auth_method.Basic{})
|
||||
group.Add(&auth_method.AccessToken{})
|
||||
group.Add(&auth_method.ActionRuntimeToken{})
|
||||
group.Add(&auth_method.ActionTaskToken{})
|
||||
|
|
@ -122,8 +126,24 @@ func buildAuthGroup() *auth_method.Group {
|
|||
return group
|
||||
}
|
||||
|
||||
// Authentication methods that are applied to Git HTTP & Git LFS HTTP routes. They are processed in the order defined;
|
||||
// an earlier authentication success would prevent later authentication methods from being attempted.
|
||||
// Authentication methods that are applied to Git LFS HTTP routes. They are processed in the order defined; an earlier
|
||||
// authentication success would prevent later authentication methods from being attempted.
|
||||
func buildGitLfsAuthGroup() *auth_method.Group {
|
||||
group := auth_method.NewGroup()
|
||||
group.Add(&auth_method.LFSToken{})
|
||||
group.Add(&auth_method.Basic{})
|
||||
group.Add(&auth_method.AccessToken{})
|
||||
group.Add(&auth_method.ActionTaskToken{})
|
||||
group.Add(&auth_method.AuthorizedIntegration{
|
||||
// "Authorization: Basic ..." is easier to use for git operations, and already supported for other tokens, so it
|
||||
// is enabled for Authorized Integrations as well:
|
||||
PermitBasic: true,
|
||||
})
|
||||
return group
|
||||
}
|
||||
|
||||
// Authentication methods that are applied to Git HTTP routes. They are processed in the order defined; an earlier
|
||||
// authentication success would prevent later authentication methods from being attempted.
|
||||
func buildGitAuthGroup() *auth_method.Group {
|
||||
group := auth_method.NewGroup()
|
||||
group.Add(&auth_method.OAuth2{})
|
||||
|
|
@ -340,24 +360,10 @@ func Routes() *web.Route {
|
|||
// TODO: GetNotificationCount & GetActiveStopwatch really seem like things that could be folded into Contexter or as helper functions
|
||||
user.GetNotificationCount, repo.GetActiveStopwatch,
|
||||
goGet)
|
||||
|
||||
// /{username}/{repo}/info/lfs routes currently need to use buildAuthGroup(), not buildGitAuthGroup(), because:
|
||||
//
|
||||
// a) there are tests that use session auth to access the LFS endpoints (TestAPILFSLocksLogged), which may be a test
|
||||
// error, and
|
||||
//
|
||||
// b) if AuthorizedIntegration is included then JWTs generated by the LFS system with `setting.LFS.SigningKey` will
|
||||
// return AuthenticationAttemptedIncorrectCredential from AuthorizedIntegration, and cause the requests to 401
|
||||
// before reaching the LFS handlers.
|
||||
//
|
||||
// (a) is probably an error that can be fixed, and (b) should be fixed by changing LFS's JWT handling to be an
|
||||
// `auth_service.Method` implementation which would be incorporated into the auth group, so that LFS isn't doing
|
||||
// it's own "after the authentication" authentication. In the interm, it's split out from the `registerRoutes` call
|
||||
// above because at least fewer middlewares can be safely applied.
|
||||
routes.Group("",
|
||||
func() {
|
||||
registerGitLFSRoutes(routes)
|
||||
}, gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildAuthGroup()), goGet)
|
||||
}, gzipMid, common.Sessioner(), context.Contexter(), webAuth(buildGitLfsAuthGroup()), goGet)
|
||||
routes.Group("",
|
||||
func() {
|
||||
registerGitRoutes(routes)
|
||||
|
|
|
|||
42
services/auth/method/auth_result_lfs_token.go
Normal file
42
services/auth/method/auth_result_lfs_token.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package method
|
||||
|
||||
import (
|
||||
auth_model "forgejo.org/models/auth"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/services/auth"
|
||||
"forgejo.org/services/authz"
|
||||
)
|
||||
|
||||
var _ auth.AuthenticationResult = &lfsTokenAuthenticationResult{}
|
||||
|
||||
type lfsTokenAuthenticationResult struct {
|
||||
*auth.BaseAuthenticationResult
|
||||
user *user_model.User
|
||||
claims *lfs.Claims
|
||||
}
|
||||
|
||||
func (r *lfsTokenAuthenticationResult) User() *user_model.User {
|
||||
return r.user
|
||||
}
|
||||
|
||||
func (r *lfsTokenAuthenticationResult) Scope() optional.Option[auth_model.AccessTokenScope] {
|
||||
if r.claims.Op == "download" {
|
||||
return optional.Some(auth_model.AccessTokenScopeReadRepository)
|
||||
}
|
||||
return optional.Some(auth_model.AccessTokenScopeWriteRepository)
|
||||
}
|
||||
|
||||
func (r *lfsTokenAuthenticationResult) Reducer() authz.AuthorizationReducer {
|
||||
return &authz.SpecificReposAuthorizationReducer{
|
||||
ResourceRepos: []authz.RepoGetter{r},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *lfsTokenAuthenticationResult) GetTargetRepoID() int64 {
|
||||
return r.claims.RepoID
|
||||
}
|
||||
66
services/auth/method/lfs_token.go
Normal file
66
services/auth/method/lfs_token.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package method
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/auth"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var _ auth.Method = &LFSToken{}
|
||||
|
||||
// LFSToken is an authentication method used when access a git repository over ssh which has LFS resources in-use. The
|
||||
// LFS client will issue a `git-lfs-authenticate` command over the ssh connection, and Forgejo will provide a
|
||||
// supplemental HTTP header "Authorization: Bearer ..." with a JWT. The LFS client can then make HTTP requests to LFS
|
||||
// endpoints with that supplemental header in order to inherit the permissions of the SSH user and to retrieve LFS
|
||||
// objects.
|
||||
type LFSToken struct{}
|
||||
|
||||
func (a *LFSToken) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
|
||||
hasToken, tokenText := tokenFromAuthorizationBearer(req).Get()
|
||||
if !hasToken {
|
||||
return &auth.AuthenticationNotAttempted{}
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenText, &lfs.Claims{}, func(t *jwt.Token) (any, error) {
|
||||
k := setting.LFS.SigningKey
|
||||
if t.Method != k.SigningMethod() {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return k.VerifyKey(), nil
|
||||
})
|
||||
if err != nil {
|
||||
return &auth.AuthenticationAttemptedIncorrectCredential{Error: err}
|
||||
}
|
||||
|
||||
claims, claimsOk := token.Claims.(*lfs.Claims)
|
||||
if !token.Valid {
|
||||
return &auth.AuthenticationAttemptedIncorrectCredential{Error: errors.New("not a valid LFS JWT")}
|
||||
} else if !claimsOk {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("claim object %v was not an lfs.Claims instance", token.Claims)}
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(req.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("unable to load claim user %d: %w", claims.UserID, err)}
|
||||
}
|
||||
if !u.IsAccessAllowed(req.Context()) {
|
||||
return &auth.AuthenticationError{Error: fmt.Errorf("user access is not permitted")}
|
||||
}
|
||||
|
||||
return &auth.AuthenticationSuccess{
|
||||
Result: &lfsTokenAuthenticationResult{
|
||||
user: u,
|
||||
claims: claims,
|
||||
},
|
||||
}
|
||||
}
|
||||
160
services/auth/method/lfs_token_test.go
Normal file
160
services/auth/method/lfs_token_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package method
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/auth"
|
||||
"forgejo.org/services/authz"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type authTokenOptions struct {
|
||||
Op string
|
||||
UserID int64
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
func getLFSAuthTokenWithBearer(opts authTokenOptions) (string, error) {
|
||||
now := time.Now()
|
||||
claims := lfs.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
RepoID: opts.RepoID,
|
||||
Op: opts.Op,
|
||||
UserID: opts.UserID,
|
||||
}
|
||||
|
||||
// Sign and get the complete encoded token as a string using the secret
|
||||
tokenString, err := setting.LFS.SigningKey.JWT(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign LFS JWT token: %w", err)
|
||||
}
|
||||
return "Bearer " + tokenString, nil
|
||||
}
|
||||
|
||||
func testAuthenticate(t *testing.T, cfg string) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
var err error
|
||||
setting.CfgProvider, err = setting.NewConfigProviderFromData(cfg)
|
||||
require.NoError(t, err, "Config")
|
||||
setting.LoadCommonSettings()
|
||||
|
||||
t.Run("no bearer token", func(t *testing.T) {
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
output := a.Verify(req, nil, nil)
|
||||
requireOutput[*auth.AuthenticationNotAttempted](t, output)
|
||||
})
|
||||
|
||||
t.Run("bearer not a JWT", func(t *testing.T) {
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", "Bearer abc")
|
||||
output := a.Verify(req, nil, nil)
|
||||
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
|
||||
require.ErrorContains(t, err, "token is malformed")
|
||||
})
|
||||
|
||||
t.Run("token valid op=download", func(t *testing.T) {
|
||||
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
|
||||
require.NoError(t, err)
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", bearerAuth)
|
||||
output := a.Verify(req, nil, nil)
|
||||
result := requireOutput[*auth.AuthenticationSuccess](t, output).Result
|
||||
assert.EqualValues(t, 2, result.User().ID)
|
||||
assert.Equal(t, optional.Some(auth_model.AccessTokenScopeReadRepository), result.Scope())
|
||||
require.NotNil(t, result.Reducer())
|
||||
|
||||
// No direct way to query an authz.Reducer for its specific repos permitted, so this is a bit of a workaround to
|
||||
// see if it's targeting the repo from the JWT:
|
||||
repoGetter, isRepoGetter := result.(authz.RepoGetter)
|
||||
require.True(t, isRepoGetter)
|
||||
assert.EqualValues(t, 1, repoGetter.GetTargetRepoID())
|
||||
})
|
||||
|
||||
t.Run("token valid op=upload", func(t *testing.T) {
|
||||
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 2, RepoID: 24})
|
||||
require.NoError(t, err)
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", bearerAuth)
|
||||
output := a.Verify(req, nil, nil)
|
||||
result := requireOutput[*auth.AuthenticationSuccess](t, output).Result
|
||||
assert.EqualValues(t, 2, result.User().ID)
|
||||
assert.Equal(t, optional.Some(auth_model.AccessTokenScopeWriteRepository), result.Scope())
|
||||
require.NotNil(t, result.Reducer())
|
||||
|
||||
// No direct way to query an authz.Reducer for its specific repos permitted, so this is a bit of a workaround to
|
||||
// see if it's targeting the repo from the JWT:
|
||||
repoGetter, isRepoGetter := result.(authz.RepoGetter)
|
||||
require.True(t, isRepoGetter)
|
||||
assert.EqualValues(t, 24, repoGetter.GetTargetRepoID())
|
||||
})
|
||||
|
||||
t.Run("token signature malformed", func(t *testing.T) {
|
||||
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
|
||||
bearerAuth += "malformed"
|
||||
|
||||
require.NoError(t, err)
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", bearerAuth)
|
||||
output := a.Verify(req, nil, nil)
|
||||
err = requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
|
||||
require.ErrorContains(t, err, "token signature is invalid")
|
||||
})
|
||||
|
||||
t.Run("invalid user", func(t *testing.T) {
|
||||
bearerAuth, err := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 999, RepoID: 1})
|
||||
require.NoError(t, err)
|
||||
a := &LFSToken{}
|
||||
req := httptest.NewRequest("GET", "https://example.org", nil)
|
||||
req.Header.Set("Authorization", bearerAuth)
|
||||
output := a.Verify(req, nil, nil)
|
||||
err = requireOutput[*auth.AuthenticationError](t, output).Error
|
||||
require.ErrorContains(t, err, "user does not exist")
|
||||
})
|
||||
}
|
||||
|
||||
type namedCfg struct {
|
||||
name, cfg string
|
||||
}
|
||||
|
||||
var iniCommon = `[security]
|
||||
INSTALL_LOCK = true
|
||||
INTERNAL_TOKEN = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
|
||||
[oauth2]
|
||||
JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
|
||||
[server]
|
||||
LFS_START_SERVER = true
|
||||
`
|
||||
|
||||
var cfgVariants = []namedCfg{
|
||||
{name: "HS256_default", cfg: `LFS_JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_`},
|
||||
{name: "RS256", cfg: `LFS_JWT_SIGNING_ALGORITHM = RS256`},
|
||||
}
|
||||
|
||||
func TestAuthenticate(t *testing.T) {
|
||||
for _, v := range cfgVariants {
|
||||
cfg := iniCommon + v.cfg
|
||||
t.Run(v.name, func(t *testing.T) { testAuthenticate(t, cfg) })
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ func GetAuthorizationReducerForAccessToken(ctx context.Context, token *auth_mode
|
|||
for i, r := range repos {
|
||||
iface[i] = r
|
||||
}
|
||||
return &SpecificReposAuthorizationReducer{resourceRepos: iface}, nil
|
||||
return &SpecificReposAuthorizationReducer{ResourceRepos: iface}, nil
|
||||
}
|
||||
|
||||
// Validate that an access token's state is valid for creation. For example, that it doesn't have a conflicting set of
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ func TestGetAuthorizationReducerForAccessToken(t *testing.T) {
|
|||
require.True(t, ok)
|
||||
require.NotNil(t, specific)
|
||||
|
||||
require.Len(t, specific.resourceRepos, 1)
|
||||
assert.EqualValues(t, 1, specific.resourceRepos[0].GetTargetRepoID())
|
||||
require.Len(t, specific.ResourceRepos, 1)
|
||||
assert.EqualValues(t, 1, specific.ResourceRepos[0].GetTargetRepoID())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,5 +29,5 @@ func GetAuthorizationReducerForAuthorizedIntegration(ctx context.Context, ai *au
|
|||
for i, r := range repos {
|
||||
iface[i] = r
|
||||
}
|
||||
return &SpecificReposAuthorizationReducer{resourceRepos: iface}, nil
|
||||
return &SpecificReposAuthorizationReducer{ResourceRepos: iface}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ func TestGetAuthorizationReducerForAuthorizedIntegration(t *testing.T) {
|
|||
require.True(t, ok)
|
||||
require.NotNil(t, specific)
|
||||
|
||||
require.Len(t, specific.resourceRepos, 1)
|
||||
assert.EqualValues(t, 1, specific.resourceRepos[0].GetTargetRepoID())
|
||||
require.Len(t, specific.ResourceRepos, 1)
|
||||
assert.EqualValues(t, 1, specific.ResourceRepos[0].GetTargetRepoID())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import (
|
|||
// repositories that aren't listed among the specific repos, read-only access is permitted. For all other repos, no
|
||||
// access is permitted.
|
||||
type SpecificReposAuthorizationReducer struct {
|
||||
resourceRepos []RepoGetter
|
||||
ResourceRepos []RepoGetter
|
||||
}
|
||||
|
||||
type RepoGetter interface {
|
||||
|
|
@ -26,7 +26,7 @@ type RepoGetter interface {
|
|||
}
|
||||
|
||||
func (r *SpecificReposAuthorizationReducer) ReduceRepoAccess(ctx context.Context, repo *repo_model.Repository, accessMode perm.AccessMode) (perm.AccessMode, error) {
|
||||
for _, tokenRepo := range r.resourceRepos {
|
||||
for _, tokenRepo := range r.ResourceRepos {
|
||||
if tokenRepo.GetTargetRepoID() == repo.ID {
|
||||
// No restrictions as this repo is within the scope of the access token.
|
||||
return accessMode, nil
|
||||
|
|
@ -48,8 +48,8 @@ func (r *SpecificReposAuthorizationReducer) ReduceRepoAccess(ctx context.Context
|
|||
}
|
||||
|
||||
func (r *SpecificReposAuthorizationReducer) RepoReadAccessFilter() builder.Cond {
|
||||
repoIDs := make([]int64, len(r.resourceRepos))
|
||||
for i, tokenRepo := range r.resourceRepos {
|
||||
repoIDs := make([]int64, len(r.ResourceRepos))
|
||||
for i, tokenRepo := range r.ResourceRepos {
|
||||
repoIDs[i] = tokenRepo.GetTargetRepoID()
|
||||
}
|
||||
targetRepos := builder.In("repository.id", repoIDs)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func TestSpecificReposAuthorizationReducer(t *testing.T) {
|
|||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
reducer := &SpecificReposAuthorizationReducer{
|
||||
resourceRepos: []RepoGetter{
|
||||
ResourceRepos: []RepoGetter{
|
||||
&auth.AccessTokenResourceRepo{
|
||||
RepoID: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func GetListLockHandler(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
authenticated := authenticate(ctx, repository, rv.Authorization, true, false)
|
||||
authenticated := authenticate(ctx, repository, true, false)
|
||||
if !authenticated {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
||||
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
|
||||
|
|
@ -135,7 +135,6 @@ func GetListLockHandler(ctx *context.Context) {
|
|||
func PostLockHandler(ctx *context.Context) {
|
||||
userName := ctx.Params("username")
|
||||
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
|
||||
authorization := ctx.Req.Header.Get("Authorization")
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
|
||||
if err != nil {
|
||||
|
|
@ -153,7 +152,7 @@ func PostLockHandler(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
authenticated := authenticate(ctx, repository, authorization, true, true)
|
||||
authenticated := authenticate(ctx, repository, true, true)
|
||||
if !authenticated {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
||||
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
|
||||
|
|
@ -207,7 +206,6 @@ func PostLockHandler(ctx *context.Context) {
|
|||
func VerifyLockHandler(ctx *context.Context) {
|
||||
userName := ctx.Params("username")
|
||||
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
|
||||
authorization := ctx.Req.Header.Get("Authorization")
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
|
||||
if err != nil {
|
||||
|
|
@ -225,7 +223,7 @@ func VerifyLockHandler(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
authenticated := authenticate(ctx, repository, authorization, true, true)
|
||||
authenticated := authenticate(ctx, repository, true, true)
|
||||
if !authenticated {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
||||
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
|
||||
|
|
@ -275,7 +273,6 @@ func VerifyLockHandler(ctx *context.Context) {
|
|||
func UnLockHandler(ctx *context.Context) {
|
||||
userName := ctx.Params("username")
|
||||
repoName := strings.TrimSuffix(ctx.Params("reponame"), ".git")
|
||||
authorization := ctx.Req.Header.Get("Authorization")
|
||||
|
||||
repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, userName, repoName)
|
||||
if err != nil {
|
||||
|
|
@ -293,7 +290,7 @@ func UnLockHandler(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
authenticated := authenticate(ctx, repository, authorization, true, true)
|
||||
authenticated := authenticate(ctx, repository, true, true)
|
||||
if !authenticated {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
|
||||
ctx.JSON(http.StatusUnauthorized, api.LFSLockError{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
package lfs
|
||||
|
||||
import (
|
||||
stdCtx "context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
|
|
@ -27,15 +26,12 @@ import (
|
|||
quota_model "forgejo.org/models/quota"
|
||||
repo_model "forgejo.org/models/repo"
|
||||
"forgejo.org/models/unit"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/json"
|
||||
lfs_module "forgejo.org/modules/lfs"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/storage"
|
||||
"forgejo.org/services/context"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// requestContext contain variables from the HTTP request.
|
||||
|
|
@ -45,14 +41,6 @@ type requestContext struct {
|
|||
Authorization string
|
||||
}
|
||||
|
||||
// Claims is a JWT Token Claims
|
||||
type Claims struct {
|
||||
RepoID int64
|
||||
Op string
|
||||
UserID int64
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// DownloadLink builds a URL to download the object.
|
||||
func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string {
|
||||
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid))
|
||||
|
|
@ -454,7 +442,7 @@ func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requir
|
|||
return nil
|
||||
}
|
||||
|
||||
if !authenticate(ctx, repository, rc.Authorization, false, requireWrite) {
|
||||
if !authenticate(ctx, repository, false, requireWrite) {
|
||||
requireAuth(ctx)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -535,7 +523,7 @@ func writeStatusMessage(ctx *context.Context, status int, message string) {
|
|||
|
||||
// authenticate uses the authorization string to determine whether
|
||||
// or not to proceed. This server assumes an HTTP Basic auth format.
|
||||
func authenticate(ctx *context.Context, repository *repo_model.Repository, authorization string, requireSigned, requireWrite bool) bool {
|
||||
func authenticate(ctx *context.Context, repository *repo_model.Repository, requireSigned, requireWrite bool) bool {
|
||||
accessMode := perm.AccessModeRead
|
||||
if requireWrite {
|
||||
accessMode = perm.AccessModeWrite
|
||||
|
|
@ -557,92 +545,21 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
|
|||
return accessMode <= perm.AccessModeWrite
|
||||
}
|
||||
|
||||
// ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess
|
||||
perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer)
|
||||
// ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess. Usage of ctx.Authentication.Reducer()
|
||||
// or .Scope() is also unnecessary here in `authenticate` -- context.CheckRepoScopedToken is used after
|
||||
// authentication to provide authorization checks.
|
||||
repoPerm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserRepoPermission for user %-v in repo %-v Error: %v", ctx.Doer, repository, err)
|
||||
return false
|
||||
}
|
||||
|
||||
canRead := perm.CanAccess(accessMode, unit.TypeCode)
|
||||
canRead := repoPerm.CanAccess(accessMode, unit.TypeCode)
|
||||
if canRead && (!requireSigned || ctx.IsSigned) {
|
||||
return true
|
||||
}
|
||||
|
||||
user, err := parseToken(ctx, authorization, repository, accessMode)
|
||||
if err != nil {
|
||||
// Most of these are Warn level - the true internal server errors are logged in parseToken already
|
||||
log.Warn("Authentication failure for provided token with Error: %v", err)
|
||||
return false
|
||||
}
|
||||
ctx.Doer = user
|
||||
return true
|
||||
}
|
||||
|
||||
func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repository, mode perm.AccessMode) (*user_model.User, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenSHA, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
k := setting.LFS.SigningKey
|
||||
if t.Method != k.SigningMethod() {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return k.VerifyKey(), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
claims, claimsOk := token.Claims.(*Claims)
|
||||
if !token.Valid || !claimsOk {
|
||||
return nil, errors.New("invalid token claim")
|
||||
}
|
||||
|
||||
if claims.RepoID != target.ID {
|
||||
return nil, errors.New("invalid token claim")
|
||||
}
|
||||
|
||||
if mode == perm.AccessModeWrite && claims.Op != "upload" {
|
||||
return nil, errors.New("invalid token claim")
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !u.IsAccessAllowed(ctx) {
|
||||
return nil, errors.New("user access is blocked")
|
||||
}
|
||||
|
||||
repoPerm, err := access_model.GetUserRepoPermission(ctx, target, u)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserRepoPermission[%d]: Error: %v", claims.UserID, err)
|
||||
return nil, err
|
||||
}
|
||||
if !repoPerm.CanAccess(mode, unit.TypeCode) {
|
||||
return nil, errors.New("user does not have access to the repository")
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Repository, mode perm.AccessMode) (*user_model.User, error) {
|
||||
if authorization == "" {
|
||||
return nil, errors.New("no token")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authorization, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("no token")
|
||||
}
|
||||
tokenSHA := parts[1]
|
||||
switch strings.ToLower(parts[0]) {
|
||||
case "bearer":
|
||||
fallthrough
|
||||
case "token":
|
||||
return handleLFSToken(ctx, tokenSHA, target, mode)
|
||||
}
|
||||
return nil, errors.New("token not found")
|
||||
return false
|
||||
}
|
||||
|
||||
func requireAuth(ctx *context.Context) {
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package lfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
perm_model "forgejo.org/models/perm"
|
||||
repo_model "forgejo.org/models/repo"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/services/contexttest"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
type authTokenOptions struct {
|
||||
Op string
|
||||
UserID int64
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
func getLFSAuthTokenWithBearer(opts authTokenOptions) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
RepoID: opts.RepoID,
|
||||
Op: opts.Op,
|
||||
UserID: opts.UserID,
|
||||
}
|
||||
|
||||
// Sign and get the complete encoded token as a string using the secret
|
||||
tokenString, err := setting.LFS.SigningKey.JWT(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign LFS JWT token: %w", err)
|
||||
}
|
||||
return "Bearer " + tokenString, nil
|
||||
}
|
||||
|
||||
func testAuthenticate(t *testing.T, cfg string) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
var err error
|
||||
setting.CfgProvider, err = setting.NewConfigProviderFromData(cfg)
|
||||
require.NoError(t, err, "Config")
|
||||
setting.LoadCommonSettings()
|
||||
assert.True(t, setting.LFS.StartServer, "LFS_START_SERVER = true")
|
||||
assert.NotNil(t, setting.LFS.SigningKey, "SigningKey initialized")
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
token2, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 1})
|
||||
_, token2, _ = strings.Cut(token2, " ")
|
||||
ctx, _ := contexttest.MockContext(t, "/")
|
||||
|
||||
t.Run("handleLFSToken", func(t *testing.T) {
|
||||
u, err := handleLFSToken(ctx, "", repo1, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, u)
|
||||
|
||||
u, err = handleLFSToken(ctx, "invalid", repo1, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, u)
|
||||
|
||||
u, err = handleLFSToken(ctx, token2, repo1, perm_model.AccessModeRead)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, u.ID)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken nonexistent user", func(t *testing.T) {
|
||||
tokenMissing, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 999, RepoID: 1})
|
||||
_, tokenMissing, _ = strings.Cut(tokenMissing, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenMissing, repo1, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user does not exist")
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken nonexistent repo", func(t *testing.T) {
|
||||
tokenBadRepo, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 2, RepoID: 999})
|
||||
_, tokenBadRepo, _ = strings.Cut(tokenBadRepo, " ")
|
||||
badRepo := &repo_model.Repository{ID: 999}
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenBadRepo, badRepo, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken blocked user", func(t *testing.T) {
|
||||
tokenBlocked, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 37, RepoID: 1})
|
||||
_, tokenBlocked, _ = strings.Cut(tokenBlocked, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenBlocked, repo1, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user access is blocked")
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken no repo access", func(t *testing.T) {
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
tokenNoAccess, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 10, RepoID: 2})
|
||||
_, tokenNoAccess, _ = strings.Cut(tokenNoAccess, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenNoAccess, repo2, perm_model.AccessModeRead)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not have access to the repository")
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken upload write access allowed", func(t *testing.T) {
|
||||
tokenUploadRW, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 2, RepoID: 1})
|
||||
_, tokenUploadRW, _ = strings.Cut(tokenUploadRW, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenUploadRW, repo1, perm_model.AccessModeWrite)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, u.ID)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken upload read-only access denied", func(t *testing.T) {
|
||||
tokenUploadRO, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "upload", UserID: 10, RepoID: 1})
|
||||
_, tokenUploadRO, _ = strings.Cut(tokenUploadRO, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenUploadRO, repo1, perm_model.AccessModeWrite)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not have access to the repository")
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("handleLFSToken download read-only access allowed", func(t *testing.T) {
|
||||
tokenDownloadRO, _ := getLFSAuthTokenWithBearer(authTokenOptions{Op: "download", UserID: 10, RepoID: 1})
|
||||
_, tokenDownloadRO, _ = strings.Cut(tokenDownloadRO, " ")
|
||||
|
||||
u, err := handleLFSToken(ctx, tokenDownloadRO, repo1, perm_model.AccessModeRead)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 10, u.ID)
|
||||
})
|
||||
|
||||
t.Run("authenticate", func(t *testing.T) {
|
||||
const prefixBearer = "Bearer "
|
||||
assert.False(t, authenticate(ctx, repo1, "", true, false))
|
||||
assert.False(t, authenticate(ctx, repo1, prefixBearer+"invalid", true, false))
|
||||
assert.True(t, authenticate(ctx, repo1, prefixBearer+token2, true, false))
|
||||
})
|
||||
}
|
||||
|
||||
type namedCfg struct {
|
||||
name, cfg string
|
||||
}
|
||||
|
||||
var iniCommon = `[security]
|
||||
INSTALL_LOCK = true
|
||||
INTERNAL_TOKEN = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
|
||||
[oauth2]
|
||||
JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_ # don't use in prod
|
||||
[server]
|
||||
LFS_START_SERVER = true
|
||||
`
|
||||
|
||||
var cfgVariants = []namedCfg{
|
||||
{name: "HS256_default", cfg: `LFS_JWT_SECRET = ForgejoForgejoForgejoForgejoForgejoForgejo_`},
|
||||
{name: "RS256", cfg: `LFS_JWT_SIGNING_ALGORITHM = RS256`},
|
||||
}
|
||||
|
||||
func TestAuthenticate(t *testing.T) {
|
||||
for _, v := range cfgVariants {
|
||||
cfg := iniCommon + v.cfg
|
||||
t.Run(v.name, func(t *testing.T) { testAuthenticate(t, cfg) })
|
||||
}
|
||||
}
|
||||
|
|
@ -103,11 +103,11 @@ func TestAPILFSLocksLogged(t *testing.T) {
|
|||
|
||||
// create locks
|
||||
for _, test := range tests {
|
||||
session := loginUser(t, test.user.Name)
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", test.repo.FullName()), map[string]string{"path": test.path})
|
||||
req.AddBasicAuth(test.user.Name)
|
||||
req.Header.Set("Accept", lfs.AcceptHeader)
|
||||
req.Header.Set("Content-Type", lfs.MediaType)
|
||||
resp := session.MakeRequest(t, req, test.httpResult)
|
||||
resp := MakeRequest(t, req, test.httpResult)
|
||||
if len(test.addTime) > 0 {
|
||||
var lfsLock api.LFSLockResponse
|
||||
DecodeJSON(t, resp, &lfsLock)
|
||||
|
|
@ -121,10 +121,10 @@ func TestAPILFSLocksLogged(t *testing.T) {
|
|||
|
||||
// check creation
|
||||
for _, test := range resultsTests {
|
||||
session := loginUser(t, test.user.Name)
|
||||
req := NewRequestf(t, "GET", "/%s.git/info/lfs/locks", test.repo.FullName())
|
||||
req.AddBasicAuth(test.user.Name)
|
||||
req.Header.Set("Accept", lfs.AcceptHeader)
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var lfsLocks api.LFSLockList
|
||||
DecodeJSON(t, resp, &lfsLocks)
|
||||
assert.Len(t, lfsLocks.Locks, test.totalCount)
|
||||
|
|
@ -135,9 +135,10 @@ func TestAPILFSLocksLogged(t *testing.T) {
|
|||
}
|
||||
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/verify", test.repo.FullName()), map[string]string{})
|
||||
req.AddBasicAuth(test.user.Name)
|
||||
req.Header.Set("Accept", lfs.AcceptHeader)
|
||||
req.Header.Set("Content-Type", lfs.MediaType)
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
var lfsLocksVerify api.LFSLockListVerify
|
||||
DecodeJSON(t, resp, &lfsLocksVerify)
|
||||
assert.Len(t, lfsLocksVerify.Ours, test.oursCount)
|
||||
|
|
@ -157,11 +158,11 @@ func TestAPILFSLocksLogged(t *testing.T) {
|
|||
|
||||
// remove all locks
|
||||
for _, test := range deleteTests {
|
||||
session := loginUser(t, test.user.Name)
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", test.repo.FullName(), test.lockID), map[string]string{})
|
||||
req.AddBasicAuth(test.user.Name)
|
||||
req.Header.Set("Accept", lfs.AcceptHeader)
|
||||
req.Header.Set("Content-Type", lfs.MediaType)
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var lfsLockRep api.LFSLockResponse
|
||||
DecodeJSON(t, resp, &lfsLockRep)
|
||||
assert.Equal(t, test.lockID, lfsLockRep.Lock.ID)
|
||||
|
|
@ -170,10 +171,10 @@ func TestAPILFSLocksLogged(t *testing.T) {
|
|||
|
||||
// check that we don't have any lock
|
||||
for _, test := range resultsTests {
|
||||
session := loginUser(t, test.user.Name)
|
||||
req := NewRequestf(t, "GET", "/%s.git/info/lfs/locks", test.repo.FullName())
|
||||
req.AddBasicAuth(test.user.Name)
|
||||
req.Header.Set("Accept", lfs.AcceptHeader)
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var lfsLocks api.LFSLockList
|
||||
DecodeJSON(t, resp, &lfsLocks)
|
||||
assert.Empty(t, lfsLocks.Locks)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package integration
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
|
|
@ -83,8 +84,6 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
oid := storeObjectInRepo(t, repo.ID, &content)
|
||||
defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
|
||||
newRequest := func(t testing.TB, br *lfs.BatchRequest) *RequestWrapper {
|
||||
return NewRequestWithJSON(t, "POST", "/user2/lfs-batch-repo.git/info/lfs/objects/batch", br).
|
||||
SetHeader("Accept", lfs.AcceptHeader).
|
||||
|
|
@ -101,8 +100,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, nil)
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusBadRequest)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("InvalidOperation", func(t *testing.T) {
|
||||
|
|
@ -111,8 +111,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
req := newRequest(t, &lfs.BatchRequest{
|
||||
Operation: "dummy",
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusBadRequest)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("InvalidPointer", func(t *testing.T) {
|
||||
|
|
@ -125,8 +126,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
{Oid: oid, Size: -1},
|
||||
},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 2)
|
||||
assert.Equal(t, "dummy", br.Objects[0].Oid)
|
||||
|
|
@ -150,8 +152,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
{Oid: oid, Size: 1},
|
||||
},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.NotNil(t, br.Objects[0].Error)
|
||||
|
|
@ -171,8 +174,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6},
|
||||
},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.NotNil(t, br.Objects[0].Error)
|
||||
|
|
@ -195,8 +199,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
Operation: "download",
|
||||
Objects: []lfs.Pointer{p},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.NotNil(t, br.Objects[0].Error)
|
||||
|
|
@ -212,8 +217,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
{Oid: oid, Size: 6},
|
||||
},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.Nil(t, br.Objects[0].Error)
|
||||
|
|
@ -237,8 +243,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6},
|
||||
},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.NotNil(t, br.Objects[0].Error)
|
||||
|
|
@ -268,8 +275,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
Operation: "upload",
|
||||
Objects: []lfs.Pointer{p},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.Nil(t, br.Objects[0].Error)
|
||||
|
|
@ -293,8 +301,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
{Oid: oid, Size: 6},
|
||||
},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.Nil(t, br.Objects[0].Error)
|
||||
|
|
@ -310,8 +319,9 @@ func TestAPILFSBatch(t *testing.T) {
|
|||
{Oid: "d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0153", Size: 1},
|
||||
},
|
||||
})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
br := decodeResponse(t, resp.Body)
|
||||
assert.Len(t, br.Objects, 1)
|
||||
assert.Nil(t, br.Objects[0].Error)
|
||||
|
|
@ -338,8 +348,6 @@ func TestAPILFSUpload(t *testing.T) {
|
|||
oid := storeObjectInRepo(t, repo.ID, &content)
|
||||
defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
|
||||
newRequest := func(t testing.TB, p lfs.Pointer, content string) *RequestWrapper {
|
||||
return NewRequestWithBody(t, "PUT", path.Join("/user2/lfs-upload-repo.git/info/lfs/objects/", p.Oid, strconv.FormatInt(p.Size, 10)), strings.NewReader(content))
|
||||
}
|
||||
|
|
@ -348,8 +356,9 @@ func TestAPILFSUpload(t *testing.T) {
|
|||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, lfs.Pointer{Oid: "dummy"}, "")
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
})
|
||||
|
||||
t.Run("AlreadyExistsInStore", func(t *testing.T) {
|
||||
|
|
@ -370,13 +379,15 @@ func TestAPILFSUpload(t *testing.T) {
|
|||
|
||||
t.Run("InvalidAccess", func(t *testing.T) {
|
||||
req := newRequest(t, p, "invalid")
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
req.AddBasicAuth("user2")
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
})
|
||||
|
||||
t.Run("ValidAccess", func(t *testing.T) {
|
||||
req := newRequest(t, p, "dummy5")
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
meta, err = git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, meta)
|
||||
|
|
@ -391,24 +402,27 @@ func TestAPILFSUpload(t *testing.T) {
|
|||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, lfs.Pointer{Oid: oid, Size: 6}, "")
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("HashMismatch", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, lfs.Pointer{Oid: "2581dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a", Size: 1}, "a")
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
})
|
||||
|
||||
t.Run("SizeMismatch", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, lfs.Pointer{Oid: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", Size: 2}, "a")
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
})
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
|
|
@ -417,8 +431,9 @@ func TestAPILFSUpload(t *testing.T) {
|
|||
p := lfs.Pointer{Oid: "6ccce4863b70f258d691f59609d31b4502e1ba5199942d3bc5d35d17a4ce771d", Size: 5}
|
||||
|
||||
req := newRequest(t, p, "gitea")
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
contentStore := lfs.NewContentStore()
|
||||
exist, err := contentStore.Exists(p)
|
||||
|
|
@ -442,8 +457,6 @@ func TestAPILFSVerify(t *testing.T) {
|
|||
oid := storeObjectInRepo(t, repo.ID, &content)
|
||||
defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
|
||||
newRequest := func(t testing.TB, p *lfs.Pointer) *RequestWrapper {
|
||||
return NewRequestWithJSON(t, "POST", "/user2/lfs-verify-repo.git/info/lfs/verify", p).
|
||||
SetHeader("Accept", lfs.AcceptHeader).
|
||||
|
|
@ -454,31 +467,140 @@ func TestAPILFSVerify(t *testing.T) {
|
|||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, nil)
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
})
|
||||
|
||||
t.Run("InvalidPointer", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, &lfs.Pointer{})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
})
|
||||
|
||||
t.Run("PointerNotExisting", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, &lfs.Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusNotFound)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := newRequest(t, &lfs.Pointer{Oid: oid, Size: 6})
|
||||
req.AddBasicAuth("user2")
|
||||
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
// Accessing git LFS resources uses CheckRepoScopedToken to validate a PAT; here we run that through all variations of
|
||||
// access token resource access to ensure it is accurately applied for LFS access.
|
||||
func TestAPILFSScopeAndResources(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
writeOperation := func(t *testing.T, repoFullName, token string, expectedStatus int) {
|
||||
oid := "83de2e488b89a0aa1c97496b888120a28b0c1e15463a4adb8405578c540f36d4"
|
||||
batch := &lfs.BatchRequest{
|
||||
Operation: "upload",
|
||||
Objects: []lfs.Pointer{
|
||||
{Oid: oid, Size: 6},
|
||||
},
|
||||
}
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/objects/batch", repoFullName), batch).
|
||||
SetHeader("Accept", lfs.AcceptHeader).
|
||||
SetHeader("Content-Type", lfs.MediaType)
|
||||
req.Request.SetBasicAuth("any", token)
|
||||
MakeRequest(t, req, expectedStatus)
|
||||
}
|
||||
readOperation := func(t *testing.T, repoFullName, token string, expectedStatus int) {
|
||||
oid := "83de2e488b89a0aa1c97496b888120a28b0c1e15463a4adb8405578c540f36d4"
|
||||
batch := &lfs.BatchRequest{
|
||||
Operation: "download",
|
||||
Objects: []lfs.Pointer{
|
||||
{Oid: oid, Size: 6},
|
||||
},
|
||||
}
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/objects/batch", repoFullName), batch).
|
||||
SetHeader("Accept", lfs.AcceptHeader).
|
||||
SetHeader("Content-Type", lfs.MediaType)
|
||||
req.Request.SetBasicAuth("any", token)
|
||||
MakeRequest(t, req, expectedStatus)
|
||||
}
|
||||
|
||||
t.Run("read-write access token", func(t *testing.T) {
|
||||
session := loginUser(t, "user2")
|
||||
allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
t.Run("allowed public repo1", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo1", allToken, http.StatusOK)
|
||||
writeOperation(t, "user2/repo1", allToken, http.StatusOK)
|
||||
})
|
||||
t.Run("allowed private repo2", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo2", allToken, http.StatusOK)
|
||||
writeOperation(t, "user2/repo2", allToken, http.StatusOK)
|
||||
})
|
||||
// repo16 is a second repo used in fine-grain testing below, so we include it in other tests as a baseline
|
||||
t.Run("allowed private repo16", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo16", allToken, http.StatusOK)
|
||||
writeOperation(t, "user2/repo16", allToken, http.StatusOK)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("read-only access token", func(t *testing.T) {
|
||||
session := loginUser(t, "user2")
|
||||
allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
||||
|
||||
t.Run("allowed public repo1", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo1", allToken, http.StatusOK)
|
||||
writeOperation(t, "user2/repo1", allToken, http.StatusForbidden)
|
||||
})
|
||||
t.Run("allowed private repo2", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo2", allToken, http.StatusOK)
|
||||
writeOperation(t, "user2/repo2", allToken, http.StatusForbidden)
|
||||
})
|
||||
// repo16 is a second repo used in fine-grain testing below, so we include it in other tests as a baseline
|
||||
t.Run("allowed private repo16", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo16", allToken, http.StatusOK)
|
||||
writeOperation(t, "user2/repo16", allToken, http.StatusForbidden)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("public-only access token", func(t *testing.T) {
|
||||
session := loginUser(t, "user2")
|
||||
publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadRepository)
|
||||
|
||||
t.Run("allowed public repo1", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo1", publicOnlyToken, http.StatusOK)
|
||||
})
|
||||
t.Run("denied private repo2", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo2", publicOnlyToken, http.StatusForbidden)
|
||||
})
|
||||
t.Run("denied private repo16", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo16", publicOnlyToken, http.StatusForbidden)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("specific repo access token", func(t *testing.T) {
|
||||
repo2OnlyToken := createFineGrainedRepoAccessToken(t, "user2",
|
||||
[]auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository},
|
||||
[]int64{2},
|
||||
)
|
||||
|
||||
t.Run("allowed public repo1", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo1", repo2OnlyToken, http.StatusOK)
|
||||
})
|
||||
t.Run("allowed inside fine-grain repo2", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo2", repo2OnlyToken, http.StatusOK)
|
||||
})
|
||||
t.Run("denied private outside fine-grain repo16", func(t *testing.T) {
|
||||
readOperation(t, "user2/repo16", repo2OnlyToken, http.StatusForbidden)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,9 +138,10 @@ func TestLFSLockView(t *testing.T) {
|
|||
lockID := ""
|
||||
{
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", repo3.FullName()), map[string]string{"path": lockPath})
|
||||
req.AddBasicAuth(user2.Name)
|
||||
req.Header.Set("Accept", lfs.AcceptHeader)
|
||||
req.Header.Set("Content-Type", lfs.MediaType)
|
||||
resp := session.MakeRequest(t, req, http.StatusCreated)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
lockResp := &api.LFSLockResponse{}
|
||||
DecodeJSON(t, resp, lockResp)
|
||||
lockID = lockResp.Lock.ID
|
||||
|
|
@ -148,9 +149,10 @@ func TestLFSLockView(t *testing.T) {
|
|||
defer func() {
|
||||
// release the lock
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", repo3.FullName(), lockID), map[string]string{})
|
||||
req.AddBasicAuth(user2.Name)
|
||||
req.Header.Set("Accept", lfs.AcceptHeader)
|
||||
req.Header.Set("Content-Type", lfs.MediaType)
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}()
|
||||
|
||||
t.Run("owner name", func(t *testing.T) {
|
||||
|
|
@ -161,6 +163,7 @@ func TestLFSLockView(t *testing.T) {
|
|||
require.NotEqual(t, user2.DisplayName(), repo3.Owner.DisplayName())
|
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings/lfs/locks", repo3.FullName()))
|
||||
req.AddBasicAuth(user2.Name)
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
doc := NewHTMLParser(t, resp.Body).doc
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue